Update application functionality and supporting components
This commit is contained in:
parent
cd64ee4ef7
commit
efd4a84c89
@ -12,6 +12,7 @@ application consists of a Django/DRF ASGI backend and a Next.js frontend.
|
||||
- requests, approval workflows, tasks and notification-driven status changes;
|
||||
- documents, attachments, comments and acknowledgements;
|
||||
- procurement requests, approval routes, budgets and equipment inventory;
|
||||
- local projects, project teams, external project bindings and daily time allocation;
|
||||
- calendars, shared events and attendance analysis through LogStorm;
|
||||
- chats, reactions, polls and user notifications over one authenticated WebSocket connection;
|
||||
- notification delivery through WebSocket, email and Web Push.
|
||||
@ -35,6 +36,7 @@ and [`frontend/package.json`](frontend/package.json).
|
||||
- [WebSocket and realtime](docs/guides/WEBSOCKET.md)
|
||||
- [Notifications](docs/guides/NOTIFICATIONS.md)
|
||||
- [Commerce and MoySklad](docs/guides/COMMERCE.md)
|
||||
- [Local projects and daily participation](backend/projects/README.md)
|
||||
|
||||
With the backend running, generated contracts are available at:
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, permissions, serializers
|
||||
@ -10,16 +10,21 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from commerce.models import ExternalProject, Product, ProjectPayment, SourceSystem
|
||||
from commerce.models import Product, ProjectPayment, SourceSystem
|
||||
from employees.models import Employee
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from finance.analytics.people import people_report
|
||||
from finance.analytics.projects import payment_queryset, projects_report
|
||||
from finance.analytics.projects import (
|
||||
local_project_scope,
|
||||
payment_queryset,
|
||||
projects_report,
|
||||
)
|
||||
from finance.analytics.sales import production_report, sales_report
|
||||
from finance.payroll.access import (
|
||||
can_access_payroll_management,
|
||||
has_simple_admin_access,
|
||||
)
|
||||
from projects.models import Project, ProjectExternalBinding
|
||||
from .views import NoStorePayrollResponseMixin
|
||||
|
||||
|
||||
@ -44,12 +49,17 @@ class ReportQuery(serializers.Serializer):
|
||||
def validate(self, attrs):
|
||||
if attrs.get("project") and attrs["project"] != "unassigned":
|
||||
try:
|
||||
attrs["project"] = ExternalProject.objects.get(pk=int(attrs["project"]))
|
||||
except (ValueError, OverflowError, ExternalProject.DoesNotExist):
|
||||
attrs["project"] = Project.objects.get(pk=int(attrs["project"]))
|
||||
except (ValueError, OverflowError, Project.DoesNotExist):
|
||||
raise serializers.ValidationError({"project": "Проект не найден."})
|
||||
if attrs.get("source") and attrs["project"].source_id != attrs["source"].pk:
|
||||
if (
|
||||
attrs.get("source")
|
||||
and not attrs["project"]
|
||||
.bindings.filter(source=attrs["source"], object_type="project")
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"project": "Проект относится к другому источнику."}
|
||||
{"project": "Проект не связан с выбранным источником."}
|
||||
)
|
||||
today = timezone.localdate()
|
||||
attrs.setdefault("date_from", today.replace(month=1, day=1))
|
||||
@ -89,15 +99,20 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
|
||||
permission_classes = [permissions.IsAuthenticated, AnalyticsPermission]
|
||||
|
||||
def get(self, request):
|
||||
section = request.query_params.get("section", "sales")
|
||||
if section not in ["sales", "people", "production", "projects"]:
|
||||
raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."})
|
||||
params = report_params(request)
|
||||
start, end, currency = (
|
||||
params["date_from"],
|
||||
params["date_to"],
|
||||
params["currency"],
|
||||
)
|
||||
section = request.query_params.get("section", "sales")
|
||||
if section not in ["sales", "people", "production", "projects"]:
|
||||
raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."})
|
||||
project_scope = (
|
||||
local_project_scope(params.get("source"), params.get("project"))
|
||||
if section != "people"
|
||||
else None
|
||||
)
|
||||
result = {
|
||||
"period": {"from": start, "to": end},
|
||||
"currency": currency,
|
||||
@ -129,7 +144,7 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
|
||||
currency,
|
||||
params.get("source"),
|
||||
product_ids,
|
||||
params.get("project"),
|
||||
project_scope=project_scope,
|
||||
)
|
||||
result["sales"] = sales
|
||||
result["production"] = production_report(
|
||||
@ -143,6 +158,7 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
|
||||
sales,
|
||||
params.get("source"),
|
||||
params.get("project"),
|
||||
project_scope,
|
||||
)
|
||||
return Response(json_values(result))
|
||||
|
||||
@ -151,6 +167,9 @@ class ReportingEntrySerializer(serializers.ModelSerializer):
|
||||
project_name = serializers.CharField(
|
||||
source="project.name", read_only=True, default=""
|
||||
)
|
||||
external_project_name = serializers.CharField(
|
||||
source="external_project.name", read_only=True, default=""
|
||||
)
|
||||
product_name = serializers.CharField(
|
||||
source="product.name", read_only=True, default=""
|
||||
)
|
||||
@ -169,6 +188,7 @@ class ReportingEntrySerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"project",
|
||||
"project_name",
|
||||
"external_project_name",
|
||||
"product",
|
||||
"product_name",
|
||||
"employee",
|
||||
@ -222,7 +242,7 @@ class ReportingEntryListView(NoStorePayrollResponseMixin, generics.ListCreateAPI
|
||||
qs = ReportingEntry.objects.filter(
|
||||
date__range=(params["date_from"], params["date_to"]),
|
||||
currency=params["currency"],
|
||||
).select_related("product", "employee", "project")
|
||||
).select_related("product", "employee", "project", "external_project")
|
||||
if params.get("project") is not None:
|
||||
qs = qs.filter(
|
||||
project_id=None
|
||||
@ -281,11 +301,21 @@ class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView):
|
||||
"sources": list(
|
||||
SourceSystem.objects.order_by("name").values("id", "name")
|
||||
),
|
||||
"projects": list(
|
||||
ExternalProject.objects.values(
|
||||
"id", "name", "source_id", "archived"
|
||||
)
|
||||
),
|
||||
"projects": [
|
||||
{
|
||||
"id": project.pk,
|
||||
"name": project.name,
|
||||
"source_ids": sorted(
|
||||
{
|
||||
binding.source_id
|
||||
for binding in project.bindings.all()
|
||||
if binding.object_type == "project"
|
||||
}
|
||||
),
|
||||
"archived": project.status == Project.Status.ARCHIVED,
|
||||
}
|
||||
for project in Project.objects.prefetch_related("bindings")
|
||||
],
|
||||
"can_write": can_write(request.user),
|
||||
"can_void": has_simple_admin_access(request.user)
|
||||
or request.user.has_perm("finance.change_reportingentry"),
|
||||
@ -294,17 +324,22 @@ class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView):
|
||||
|
||||
|
||||
class ProjectPaymentSerializer(serializers.ModelSerializer):
|
||||
project_name = serializers.CharField(
|
||||
source="project.name", default="Без проекта", read_only=True
|
||||
project_name = serializers.SerializerMethodField()
|
||||
external_project_name = serializers.CharField(
|
||||
source="project.name", default="", read_only=True
|
||||
)
|
||||
source_name = serializers.CharField(source="source.name", read_only=True)
|
||||
|
||||
def get_project_name(self, obj):
|
||||
return obj.local_project_name or "Без привязки"
|
||||
|
||||
class Meta:
|
||||
model = ProjectPayment
|
||||
fields = [
|
||||
"id",
|
||||
"project",
|
||||
"project_name",
|
||||
"external_project_name",
|
||||
"source_name",
|
||||
"document_type",
|
||||
"number",
|
||||
@ -323,10 +358,16 @@ class ProjectPaymentListView(NoStorePayrollResponseMixin, generics.ListAPIView):
|
||||
|
||||
def get_queryset(self):
|
||||
p = report_params(self.request)
|
||||
scope = local_project_scope(p.get("source"), p.get("project"))
|
||||
binding = ProjectExternalBinding.objects.filter(
|
||||
source_id=OuterRef("source_id"),
|
||||
object_type="project",
|
||||
external_id=OuterRef("project__external_id"),
|
||||
)
|
||||
return payment_queryset(
|
||||
p["date_from"],
|
||||
p["date_to"],
|
||||
p["currency"],
|
||||
p.get("source"),
|
||||
p.get("project"),
|
||||
)
|
||||
scope=scope,
|
||||
).annotate(local_project_name=Subquery(binding.values("project__name")[:1]))
|
||||
|
||||
0
backend/api/v1/projects/__init__.py
Normal file
0
backend/api/v1/projects/__init__.py
Normal file
185
backend/api/v1/projects/serializers.py
Normal file
185
backend/api/v1/projects/serializers.py
Normal file
@ -0,0 +1,185 @@
|
||||
from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from commerce.models import SourceSystem
|
||||
from projects.models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
ProjectStage,
|
||||
ProjectExternalBinding,
|
||||
ProjectResource,
|
||||
)
|
||||
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
manager_name = serializers.SerializerMethodField()
|
||||
can_manage = serializers.SerializerMethodField()
|
||||
can_view_finances = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"customer",
|
||||
"manager",
|
||||
"manager_name",
|
||||
"status",
|
||||
"starts_on",
|
||||
"ends_on",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"can_manage",
|
||||
"can_view_finances",
|
||||
]
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
extra_kwargs = {
|
||||
"manager": {"queryset": get_user_model().objects.filter(is_active=True)}
|
||||
}
|
||||
|
||||
def get_manager_name(self, obj):
|
||||
return str(obj.manager)
|
||||
|
||||
def get_can_manage(self, obj):
|
||||
from projects.permissions import can_manage
|
||||
|
||||
return can_manage(self.context["request"].user, obj)
|
||||
|
||||
def get_can_view_finances(self, obj):
|
||||
cached = getattr(self, "_can_view_finances", None)
|
||||
if cached is None:
|
||||
from finance.payroll.access import can_access_payroll_management
|
||||
|
||||
cached = can_access_payroll_management(self.context["request"].user)
|
||||
self._can_view_finances = cached
|
||||
return cached
|
||||
|
||||
def validate(self, attrs):
|
||||
start = attrs.get(
|
||||
"starts_on", self.instance.starts_on if self.instance else None
|
||||
)
|
||||
end = attrs.get("ends_on", self.instance.ends_on if self.instance else None)
|
||||
if start and end and end < start:
|
||||
raise serializers.ValidationError("Окончание не может быть раньше начала.")
|
||||
return attrs
|
||||
|
||||
|
||||
class MembershipSerializer(serializers.ModelSerializer):
|
||||
employee_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProjectMembership
|
||||
fields = [
|
||||
"id",
|
||||
"employee",
|
||||
"employee_name",
|
||||
"role",
|
||||
"planned_percent",
|
||||
"starts_on",
|
||||
"ends_on",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"planned_percent": {"min_value": 0, "max_value": 100},
|
||||
"employee": {"queryset": get_user_model().objects.filter(is_active=True)},
|
||||
}
|
||||
|
||||
def get_employee_name(self, obj):
|
||||
return str(obj.employee)
|
||||
|
||||
def validate(self, attrs):
|
||||
if (
|
||||
attrs.get("starts_on")
|
||||
and attrs.get("ends_on")
|
||||
and attrs["ends_on"] < attrs["starts_on"]
|
||||
):
|
||||
raise serializers.ValidationError("Окончание участия раньше начала.")
|
||||
return attrs
|
||||
|
||||
|
||||
class StageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProjectStage
|
||||
fields = ["id", "name", "due_on", "done", "position"]
|
||||
|
||||
|
||||
class BindingSerializer(serializers.ModelSerializer):
|
||||
source_name = serializers.CharField(source="source.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectExternalBinding
|
||||
fields = [
|
||||
"id",
|
||||
"source",
|
||||
"source_name",
|
||||
"object_type",
|
||||
"external_id",
|
||||
"external_name",
|
||||
]
|
||||
validators = []
|
||||
|
||||
|
||||
class ResourceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProjectResource
|
||||
fields = ["id", "title", "kind", "url"]
|
||||
|
||||
def validate(self, attrs):
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
url = urlsplit(attrs["url"])
|
||||
prefix = {
|
||||
"task": "/tasks",
|
||||
"procurement": "/procurement",
|
||||
"document": "/documents",
|
||||
}[attrs["kind"]]
|
||||
if (
|
||||
url.scheme
|
||||
or url.netloc
|
||||
or "\\" in attrs["url"]
|
||||
or not (url.path == prefix or url.path.startswith(prefix + "/"))
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Укажите внутреннюю ссылку соответствующего раздела портала."
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class AllocationRowSerializer(serializers.Serializer):
|
||||
project = serializers.IntegerField(min_value=1, required=False, allow_null=True)
|
||||
activity = serializers.ChoiceField(
|
||||
choices=["", "department", "training", "support"], default=""
|
||||
)
|
||||
percent = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, min_value=Decimal("0.01"), max_value=100
|
||||
)
|
||||
|
||||
|
||||
class DaySerializer(serializers.Serializer):
|
||||
date = serializers.DateField()
|
||||
version = serializers.IntegerField(min_value=0)
|
||||
status = serializers.ChoiceField(choices=["draft", "confirmed"])
|
||||
note = serializers.CharField(max_length=2000, allow_blank=True, default="")
|
||||
allocations = AllocationRowSerializer(many=True, max_length=50)
|
||||
|
||||
|
||||
class ExternalProjectImportSerializer(serializers.Serializer):
|
||||
source = serializers.PrimaryKeyRelatedField(queryset=SourceSystem.objects.all())
|
||||
external_id = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=5000, allow_blank=True, default="")
|
||||
customer = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
manager = serializers.PrimaryKeyRelatedField(
|
||||
queryset=get_user_model().objects.filter(is_active=True)
|
||||
)
|
||||
status = serializers.ChoiceField(choices=Project.Status.choices, default="planned")
|
||||
starts_on = serializers.DateField(required=False, allow_null=True)
|
||||
ends_on = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get("starts_on") and attrs.get("ends_on"):
|
||||
if attrs["ends_on"] < attrs["starts_on"]:
|
||||
raise serializers.ValidationError(
|
||||
"Окончание не может быть раньше начала."
|
||||
)
|
||||
return attrs
|
||||
12
backend/api/v1/projects/urls.py
Normal file
12
backend/api/v1/projects/urls.py
Normal file
@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ProjectViewSet, OwnDayView, OwnCalendarView, ReminderView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ProjectViewSet, basename="local-project")
|
||||
urlpatterns = [
|
||||
path("my-day/", OwnDayView.as_view()),
|
||||
path("my-calendar/", OwnCalendarView.as_view()),
|
||||
path("reminder/", ReminderView.as_view()),
|
||||
*router.urls,
|
||||
]
|
||||
730
backend/api/v1/projects/views.py
Normal file
730
backend/api/v1/projects/views.py
Normal file
@ -0,0 +1,730 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import Count, Exists, Max, Min, OuterRef, Sum, Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import permissions, serializers, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from commerce.models import (
|
||||
ExternalProject,
|
||||
ProjectPayment,
|
||||
Sale,
|
||||
SourceRecord,
|
||||
SourceSystem,
|
||||
)
|
||||
from projects.models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
ProjectExternalBinding,
|
||||
ProjectEvent,
|
||||
TimeAllocation,
|
||||
DailyAllocation,
|
||||
AllocationReminder,
|
||||
ProjectBoardLink,
|
||||
)
|
||||
from projects.permissions import visible_projects, can_create, can_manage
|
||||
from projects.services.time_tracking import day_data, save_day, claim_reminder
|
||||
from tasks.access import task_board_access_q
|
||||
from tasks.application import link_task_object, unlink_task_object
|
||||
from tasks.models import Task, TaskBoard, TaskLinkedObject, TaskLinkedObjectKind
|
||||
from tasks.presentation import linked_task_badges_for_object
|
||||
from .serializers import (
|
||||
ProjectSerializer,
|
||||
MembershipSerializer,
|
||||
StageSerializer,
|
||||
BindingSerializer,
|
||||
ResourceSerializer,
|
||||
DaySerializer,
|
||||
ExternalProjectImportSerializer,
|
||||
)
|
||||
|
||||
|
||||
class PrivateResponseMixin:
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
response = super().finalize_response(request, response, *args, **kwargs)
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
|
||||
|
||||
class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = ProjectSerializer
|
||||
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = visible_projects(self.request.user).select_related("manager")
|
||||
if self.request.query_params.get("mine") == "true":
|
||||
qs = qs.filter(
|
||||
Q(manager=self.request.user)
|
||||
| Q(memberships__employee=self.request.user)
|
||||
).distinct()
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
return (
|
||||
qs.filter(Q(name__icontains=search) | Q(customer__icontains=search))
|
||||
if search
|
||||
else qs
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if not can_create(self.request.user):
|
||||
raise PermissionDenied(
|
||||
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
|
||||
)
|
||||
with transaction.atomic():
|
||||
p = serializer.save(created_by=self.request.user)
|
||||
ProjectMembership.objects.create(
|
||||
project=p,
|
||||
employee=p.manager,
|
||||
role="Руководитель",
|
||||
starts_on=timezone.localdate(),
|
||||
)
|
||||
ProjectEvent.objects.create(
|
||||
project=p, actor=self.request.user, action="Проект создан"
|
||||
)
|
||||
|
||||
def require_manager(self):
|
||||
p = self.get_object()
|
||||
if not can_manage(self.request.user, p):
|
||||
raise PermissionDenied("Управление доступно руководителю проекта.")
|
||||
return p
|
||||
|
||||
def perform_update(self, serializer):
|
||||
p = self.require_manager()
|
||||
with transaction.atomic():
|
||||
serializer.save()
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=self.request.user,
|
||||
action="Карточка изменена",
|
||||
details={k: str(v) for k, v in serializer.validated_data.items()},
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
raise ValidationError("Проекты сохраняют историю. Используйте статус «Архив».")
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def options(self, request):
|
||||
# Employee directory visibility follows the portal's authenticated directory.
|
||||
employees = (
|
||||
get_user_model()
|
||||
.objects.filter(is_active=True)
|
||||
.order_by("last_name", "first_name")
|
||||
)
|
||||
manageable = visible_projects(request.user)
|
||||
if not (request.user.is_staff or request.user.is_superuser):
|
||||
manageable = manageable.filter(manager=request.user)
|
||||
return Response(
|
||||
{
|
||||
"can_create": can_create(request.user),
|
||||
"employees": [{"id": u.pk, "name": str(u)} for u in employees],
|
||||
"manageable_projects": list(
|
||||
manageable.order_by("name").values("id", "name")[:500]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="external-candidates")
|
||||
def external_candidates(self, request):
|
||||
page = serializers.IntegerField(min_value=1).run_validation(
|
||||
request.query_params.get("page", 1)
|
||||
)
|
||||
page_size = 24
|
||||
search = request.query_params.get("q", "").strip()
|
||||
binding = ProjectExternalBinding.objects.filter(
|
||||
source_id=OuterRef("source_id"),
|
||||
object_type="project",
|
||||
external_id=OuterRef("external_id"),
|
||||
)
|
||||
rows = (
|
||||
ExternalProject.objects.filter(archived=False)
|
||||
.annotate(is_bound=Exists(binding))
|
||||
.filter(is_bound=False)
|
||||
.select_related("source")
|
||||
.order_by("name", "pk")
|
||||
)
|
||||
if search:
|
||||
rows = rows.filter(name__icontains=search)
|
||||
count = rows.count()
|
||||
start = (page - 1) * page_size
|
||||
return Response(
|
||||
{
|
||||
"count": count,
|
||||
"next": page + 1 if start + page_size < count else None,
|
||||
"previous": page - 1 if page > 1 else None,
|
||||
"results": [
|
||||
{
|
||||
"source": row.source_id,
|
||||
"source_name": row.source.name,
|
||||
"external_id": row.external_id,
|
||||
"external_name": row.name,
|
||||
"bound": False,
|
||||
}
|
||||
for row in rows[start : start + page_size]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="external-project")
|
||||
def external_project(self, request):
|
||||
source_id = serializers.IntegerField(min_value=1).run_validation(
|
||||
request.query_params.get("source")
|
||||
)
|
||||
external_id = serializers.CharField(max_length=255).run_validation(
|
||||
request.query_params.get("external_id")
|
||||
)
|
||||
external = get_object_or_404(
|
||||
ExternalProject.objects.select_related("source"),
|
||||
source_id=source_id,
|
||||
external_id=external_id,
|
||||
archived=False,
|
||||
)
|
||||
bound = ProjectExternalBinding.objects.filter(
|
||||
source_id=external.source_id,
|
||||
object_type="project",
|
||||
external_id=external.external_id,
|
||||
).exists()
|
||||
source_record = (
|
||||
SourceRecord.objects.filter(
|
||||
source_id=external.source_id,
|
||||
entity_type="project",
|
||||
external_id=external.external_id,
|
||||
)
|
||||
.only("source_updated_at")
|
||||
.first()
|
||||
)
|
||||
payment_rows = list(
|
||||
ProjectPayment.objects.filter(
|
||||
project=external,
|
||||
applicable=True,
|
||||
missing_from_source=False,
|
||||
)
|
||||
.values("currency")
|
||||
.annotate(count=Count("pk"), amount=Sum("amount"))
|
||||
.order_by("currency")
|
||||
)
|
||||
payment_period = ProjectPayment.objects.filter(
|
||||
project=external,
|
||||
applicable=True,
|
||||
missing_from_source=False,
|
||||
).aggregate(first=Min("occurred_at"), last=Max("occurred_at"))
|
||||
sales = Sale.objects.filter(project=external, counts_as_sale=True)
|
||||
sales_period = sales.aggregate(first=Min("occurred_at"), last=Max("occurred_at"))
|
||||
return Response(
|
||||
{
|
||||
"source": external.source_id,
|
||||
"source_name": external.source.name,
|
||||
"external_id": external.external_id,
|
||||
"external_name": external.name,
|
||||
"code": external.code,
|
||||
"description": external.description,
|
||||
"synced_at": external.synced_at,
|
||||
"source_updated_at": (
|
||||
source_record.source_updated_at if source_record else None
|
||||
),
|
||||
"bound": bound,
|
||||
"payment_count": sum(row["count"] for row in payment_rows),
|
||||
"payments": payment_rows,
|
||||
"payment_first_at": payment_period["first"],
|
||||
"payment_last_at": payment_period["last"],
|
||||
"sale_count": sales.count(),
|
||||
"sale_first_at": sales_period["first"],
|
||||
"sale_last_at": sales_period["last"],
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="from-external")
|
||||
def from_external(self, request):
|
||||
if not can_create(request.user):
|
||||
raise PermissionDenied(
|
||||
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
|
||||
)
|
||||
payload = ExternalProjectImportSerializer(data=request.data)
|
||||
payload.is_valid(raise_exception=True)
|
||||
values = payload.validated_data
|
||||
with transaction.atomic():
|
||||
external = get_object_or_404(
|
||||
ExternalProject.objects.select_for_update(),
|
||||
source=values["source"],
|
||||
external_id=values["external_id"],
|
||||
archived=False,
|
||||
)
|
||||
if ProjectExternalBinding.objects.filter(
|
||||
source=external.source,
|
||||
object_type="project",
|
||||
external_id=external.external_id,
|
||||
).exists():
|
||||
raise ValidationError(
|
||||
"Этот внешний проект уже привязан к локальному проекту."
|
||||
)
|
||||
project = Project.objects.create(
|
||||
name=values["name"],
|
||||
description=values["description"],
|
||||
customer=values["customer"],
|
||||
manager=values["manager"],
|
||||
status=values["status"],
|
||||
starts_on=values.get("starts_on"),
|
||||
ends_on=values.get("ends_on"),
|
||||
created_by=request.user,
|
||||
)
|
||||
ProjectMembership.objects.create(
|
||||
project=project,
|
||||
employee=project.manager,
|
||||
role="Руководитель",
|
||||
starts_on=timezone.localdate(),
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
ProjectExternalBinding.objects.create(
|
||||
project=project,
|
||||
source=external.source,
|
||||
object_type="project",
|
||||
external_id=external.external_id,
|
||||
external_name=external.name,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise ValidationError(
|
||||
"Этот внешний проект уже привязан к локальному проекту."
|
||||
) from exc
|
||||
ProjectEvent.objects.create(
|
||||
project=project,
|
||||
actor=request.user,
|
||||
action="Проект создан из внешнего проекта",
|
||||
details={
|
||||
"source": external.source.name,
|
||||
"external_id": external.external_id,
|
||||
"external_name": external.name,
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
ProjectSerializer(project, context={"request": request}).data,
|
||||
status=201,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def members(self, request, pk=None):
|
||||
p = self.get_object()
|
||||
if request.method == "GET":
|
||||
return Response(
|
||||
MembershipSerializer(
|
||||
p.memberships.select_related("employee"), many=True
|
||||
).data
|
||||
)
|
||||
self.require_manager()
|
||||
with transaction.atomic():
|
||||
if request.method == "DELETE":
|
||||
member = get_object_or_404(
|
||||
p.memberships, pk=parse_id(request.data.get("id"))
|
||||
)
|
||||
name = str(member.employee)
|
||||
member.delete()
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Участник удалён",
|
||||
details={"employee": name},
|
||||
)
|
||||
else:
|
||||
existing = p.memberships.filter(
|
||||
employee_id=parse_id(request.data.get("employee"))
|
||||
).first()
|
||||
s = MembershipSerializer(existing, data=request.data)
|
||||
s.is_valid(raise_exception=True)
|
||||
s.save(project=p)
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Участие обновлено",
|
||||
details={k: str(v) for k, v in s.validated_data.items()},
|
||||
)
|
||||
return Response(
|
||||
MembershipSerializer(
|
||||
p.memberships.select_related("employee"), many=True
|
||||
).data
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def stages(self, request, pk=None):
|
||||
return self.collection(request, "stages", StageSerializer)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def resources(self, request, pk=None):
|
||||
return self.collection(request, "resources", ResourceSerializer)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="task-links")
|
||||
def task_links(self, request, pk=None):
|
||||
p = self.get_object()
|
||||
if request.method != "GET":
|
||||
p = self.require_manager()
|
||||
content_type = ContentType.objects.get_for_model(Project)
|
||||
if request.method == "POST":
|
||||
task_id = parse_id(request.data.get("task_id"))
|
||||
task = get_object_or_404(
|
||||
Task.objects.select_related("board", "column").filter(
|
||||
board__in=TaskBoard.objects.filter(task_board_access_q(request.user))
|
||||
),
|
||||
pk=task_id,
|
||||
)
|
||||
_, created = link_task_object(
|
||||
task=task,
|
||||
actor=request.user,
|
||||
kind=TaskLinkedObjectKind.PROJECT,
|
||||
content_type=content_type,
|
||||
object_id=p.pk,
|
||||
object_label=p.name,
|
||||
object_type="Проект",
|
||||
realtime_type="project",
|
||||
)
|
||||
if created:
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Задача связана",
|
||||
details={"task_id": str(task.pk), "task": task.title},
|
||||
)
|
||||
elif request.method == "DELETE":
|
||||
link = get_object_or_404(
|
||||
TaskLinkedObject,
|
||||
pk=parse_id(request.data.get("id")),
|
||||
kind=TaskLinkedObjectKind.PROJECT,
|
||||
content_type=content_type,
|
||||
object_id=p.pk,
|
||||
)
|
||||
task_title = link.task.title
|
||||
unlink_task_object(
|
||||
task=link.task,
|
||||
actor=request.user,
|
||||
kind=TaskLinkedObjectKind.PROJECT,
|
||||
link_id=link.pk,
|
||||
object_type="Проект",
|
||||
realtime_type="project",
|
||||
)
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Связь с задачей удалена",
|
||||
details={"task": task_title},
|
||||
)
|
||||
return Response(
|
||||
linked_task_badges_for_object(
|
||||
obj=p,
|
||||
kind=TaskLinkedObjectKind.PROJECT,
|
||||
user=request.user,
|
||||
include_task_card_fields=True,
|
||||
)
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="board-links")
|
||||
def board_links(self, request, pk=None):
|
||||
p = self.get_object()
|
||||
if request.method != "GET":
|
||||
p = self.require_manager()
|
||||
accessible = TaskBoard.objects.filter(
|
||||
is_archived=False
|
||||
).filter(task_board_access_q(request.user)).distinct()
|
||||
if request.method == "POST":
|
||||
board = get_object_or_404(accessible, pk=parse_id(request.data.get("board_id")))
|
||||
_, created = ProjectBoardLink.objects.get_or_create(
|
||||
project=p,
|
||||
board=board,
|
||||
defaults={"created_by": request.user},
|
||||
)
|
||||
if created:
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Доска связана",
|
||||
details={"board_id": str(board.pk), "board": board.name},
|
||||
)
|
||||
elif request.method == "DELETE":
|
||||
link = get_object_or_404(
|
||||
p.board_links.select_related("board"),
|
||||
pk=parse_id(request.data.get("id")),
|
||||
)
|
||||
board_name = link.board.name
|
||||
link.delete()
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Связь с доской удалена",
|
||||
details={"board": board_name},
|
||||
)
|
||||
links = p.board_links.filter(board__in=accessible).select_related("board")
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": link.pk,
|
||||
"board_id": link.board_id,
|
||||
"name": link.board.name,
|
||||
"url": f"/tasks?board={link.board_id}",
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
)
|
||||
|
||||
def collection(self, request, relation, serializer_class):
|
||||
p = self.get_object()
|
||||
rows = getattr(p, relation)
|
||||
if request.method != "GET":
|
||||
self.require_manager()
|
||||
with transaction.atomic():
|
||||
obj = (
|
||||
get_object_or_404(rows, pk=parse_id(request.data["id"]))
|
||||
if request.data.get("id")
|
||||
else None
|
||||
)
|
||||
if request.method == "DELETE":
|
||||
if obj is None:
|
||||
raise ValidationError("Не указана запись.")
|
||||
obj.delete()
|
||||
else:
|
||||
s = serializer_class(obj, data=request.data)
|
||||
s.is_valid(raise_exception=True)
|
||||
s.save(project=p)
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Этапы обновлены"
|
||||
if relation == "stages"
|
||||
else "Связанные материалы обновлены",
|
||||
)
|
||||
return Response(serializer_class(rows.all(), many=True).data)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"])
|
||||
def bindings(self, request, pk=None):
|
||||
p = self.require_manager()
|
||||
if request.method != "GET":
|
||||
with transaction.atomic():
|
||||
if request.method == "DELETE":
|
||||
b = get_object_or_404(
|
||||
p.bindings, pk=parse_id(request.data.get("id"))
|
||||
)
|
||||
details = {"source": b.source_id, "external_id": b.external_id}
|
||||
b.delete()
|
||||
else:
|
||||
s = BindingSerializer(data=request.data)
|
||||
s.is_valid(raise_exception=True)
|
||||
details = {k: str(v) for k, v in s.validated_data.items()}
|
||||
try:
|
||||
with transaction.atomic():
|
||||
s.save(project=p)
|
||||
except IntegrityError:
|
||||
raise ValidationError(
|
||||
"Этот внешний объект уже привязан к локальному проекту."
|
||||
)
|
||||
ProjectEvent.objects.create(
|
||||
project=p,
|
||||
actor=request.user,
|
||||
action="Привязки обновлены",
|
||||
details=details,
|
||||
)
|
||||
return Response(
|
||||
BindingSerializer(p.bindings.select_related("source"), many=True).data
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="external-options")
|
||||
def external_options(self, request, pk=None):
|
||||
self.require_manager()
|
||||
search = request.query_params.get("q", "")
|
||||
bound = ProjectExternalBinding.objects.filter(
|
||||
object_type="project"
|
||||
).values_list("source_id", "external_id")
|
||||
identities = set(bound)
|
||||
rows = (
|
||||
ExternalProject.objects.filter(name__icontains=search)
|
||||
.select_related("source")
|
||||
.order_by("name")[:200]
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sources": list(SourceSystem.objects.values("id", "name")),
|
||||
"external_projects": [
|
||||
{
|
||||
"source": r.source_id,
|
||||
"source_name": r.source.name,
|
||||
"external_id": r.external_id,
|
||||
"external_name": r.name,
|
||||
"bound": (r.source_id, r.external_id) in identities,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def history(self, request, pk=None):
|
||||
p = self.require_manager()
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": e.pk,
|
||||
"actor": str(e.actor),
|
||||
"action": e.action,
|
||||
"details": e.details,
|
||||
"created_at": e.created_at,
|
||||
}
|
||||
for e in p.events.select_related("actor")[:100]
|
||||
]
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="time-report")
|
||||
def time_report(self, request, pk=None):
|
||||
p = self.get_object()
|
||||
today = timezone.localdate()
|
||||
start = parse_date(
|
||||
request.query_params.get("from", today.replace(day=1).isoformat())
|
||||
)
|
||||
end = parse_date(request.query_params.get("to", today.isoformat()))
|
||||
if end.year >= 9999 or end < start or (end - start).days > 366:
|
||||
raise ValidationError("Выберите период до 367 дней.")
|
||||
rows = TimeAllocation.objects.filter(
|
||||
project=p, day__date__range=[start, end]
|
||||
).select_related("day__employee")
|
||||
manager = can_manage(request.user, p)
|
||||
if not manager:
|
||||
rows = rows.filter(day__employee=request.user)
|
||||
results = []
|
||||
total = Decimal(0)
|
||||
unknown = drafts = 0
|
||||
for a in rows.order_by("-day__date", "day__employee_id"):
|
||||
hours = a.day.hours * a.percent / 100 if a.day.hours is not None else None
|
||||
if a.day.status == "confirmed":
|
||||
if hours is None:
|
||||
unknown += 1
|
||||
else:
|
||||
total += hours
|
||||
else:
|
||||
drafts += 1
|
||||
results.append(
|
||||
{
|
||||
"date": a.day.date,
|
||||
"employee": str(a.day.employee),
|
||||
"percent": a.percent,
|
||||
"hours": hours,
|
||||
"status": a.day.status,
|
||||
"hours_source": a.day.hours_source,
|
||||
}
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"from": start,
|
||||
"to": end,
|
||||
"scope": "team" if manager else "self",
|
||||
"confirmed_hours": total,
|
||||
"unknown_days": unknown,
|
||||
"draft_days": drafts,
|
||||
"rows": results,
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def finances(self, request, pk=None):
|
||||
from finance.payroll.access import can_access_payroll_management
|
||||
from finance.analytics.labor import labor_summary
|
||||
from finance.analytics.projects import payment_queryset
|
||||
|
||||
p = self.get_object()
|
||||
if not can_access_payroll_management(request.user):
|
||||
raise PermissionDenied("Нет доступа к финансовой аналитике.")
|
||||
today = timezone.localdate()
|
||||
start = parse_date(
|
||||
request.query_params.get("from", today.replace(month=1, day=1).isoformat())
|
||||
)
|
||||
end = parse_date(request.query_params.get("to", today.isoformat()))
|
||||
if end.year >= 9999 or end < start or (end - start).days > 366:
|
||||
raise ValidationError("Выберите период до 367 дней.")
|
||||
currencies = request.query_params.get("currency", "RUB")
|
||||
currencies = serializers.RegexField(r"^[A-Z]{3}$").run_validation(
|
||||
currencies.upper()
|
||||
)
|
||||
condition = Q(pk__in=[])
|
||||
for b in p.bindings.filter(object_type="project"):
|
||||
condition |= Q(source_id=b.source_id, external_id=b.external_id)
|
||||
external = ExternalProject.objects.filter(condition)
|
||||
payments = payment_queryset(start, end, currencies.upper()).filter(
|
||||
project__in=external
|
||||
)
|
||||
allocations = TimeAllocation.objects.filter(
|
||||
project=p,
|
||||
day__date__range=(start, end),
|
||||
day__status="confirmed",
|
||||
).select_related("day__employee")
|
||||
labor = labor_summary(allocations, currencies.upper())
|
||||
return Response(
|
||||
{
|
||||
"from": start,
|
||||
"to": end,
|
||||
"currency": currencies.upper(),
|
||||
"external_project_count": external.count(),
|
||||
"payment_count": payments.count(),
|
||||
"paid": payments.aggregate(total=Sum("amount"))["total"],
|
||||
"categories": list(
|
||||
payments.values("expense_category")
|
||||
.annotate(paid=Sum("amount"))
|
||||
.order_by("-paid")
|
||||
),
|
||||
**labor,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def parse_id(value):
|
||||
return serializers.IntegerField(min_value=1).run_validation(value)
|
||||
|
||||
|
||||
def parse_date(value):
|
||||
s = serializers.DateField()
|
||||
return s.run_validation(value)
|
||||
|
||||
|
||||
class OwnDayView(PrivateResponseMixin, APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
day = parse_date(
|
||||
request.query_params.get("date", timezone.localdate().isoformat())
|
||||
)
|
||||
return Response(day_data(request.user, day))
|
||||
|
||||
def post(self, request):
|
||||
s = DaySerializer(data=request.data)
|
||||
s.is_valid(raise_exception=True)
|
||||
return Response(save_day(request.user, s.validated_data))
|
||||
|
||||
|
||||
class OwnCalendarView(PrivateResponseMixin, APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
rows = DailyAllocation.objects.filter(
|
||||
employee=request.user, date__gte=timezone.localdate() - timedelta(days=90)
|
||||
)
|
||||
return Response(
|
||||
list(rows.values("date", "status", "hours", "hours_source", "version"))
|
||||
)
|
||||
|
||||
|
||||
class ReminderView(PrivateResponseMixin, APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if serializers.BooleanField().run_validation(request.data.get("snooze", False)):
|
||||
return self.snooze(request)
|
||||
return Response({"date": claim_reminder(request.user)})
|
||||
|
||||
@transaction.atomic
|
||||
def snooze(self, request):
|
||||
get_user_model().objects.select_for_update().get(pk=request.user.pk)
|
||||
AllocationReminder.objects.update_or_create(
|
||||
employee=request.user,
|
||||
defaults={
|
||||
"last_claim_date": None,
|
||||
"snoozed_until": timezone.now() + timedelta(hours=2),
|
||||
},
|
||||
)
|
||||
return Response({"date": None})
|
||||
@ -183,6 +183,7 @@ urlpatterns = [
|
||||
path("commerce/", include("api.v1.commerce.urls")),
|
||||
# Finance / payroll API
|
||||
path("finance/", include("api.v1.finance.urls")),
|
||||
path("projects/", include("api.v1.projects.urls")),
|
||||
# Tasks API
|
||||
path("tasks/", include("api.v1.tasks.urls")),
|
||||
# Guests API
|
||||
|
||||
@ -76,3 +76,14 @@ All employees may read. Writes require staff status or the corresponding
|
||||
Django model permission. The frontend management screen can bootstrap and
|
||||
correct normalized records manually; integrations should still write them
|
||||
through application services so raw provenance and idempotency are preserved.
|
||||
|
||||
## Local project management
|
||||
|
||||
Local project cards, teams and daily participation live in the sibling
|
||||
[`projects`](../projects/README.md) app. `ProjectExternalBinding` explicitly maps
|
||||
source object identities to local projects. Commerce synchronization preserves
|
||||
external projects and payments and never overwrites local project management data.
|
||||
Active external projects without a binding appear in the `/projects` intake queue,
|
||||
where a manager can create or select their local project explicitly.
|
||||
Company analytics groups imported sales, costs and project payments by these exact
|
||||
bindings. Unbound source records remain visible as a separate reconciliation group.
|
||||
|
||||
@ -79,6 +79,7 @@ INSTALLED_APPS = [
|
||||
"finance.apps.FinanceConfig",
|
||||
"procurement.apps.ProcurementConfig",
|
||||
"commerce.apps.CommerceConfig",
|
||||
"projects.apps.ProjectsConfig",
|
||||
"tasks.apps.TasksConfig",
|
||||
"guests.apps.GuestsConfig",
|
||||
"push_notifications", # django-push-notifications для Web Push
|
||||
|
||||
311
backend/finance/analytics/labor.py
Normal file
311
backend/finance/analytics/labor.py
Normal file
@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
|
||||
from finance.models import EmployeePayRate, PayrollPeriod, PayrollStatement
|
||||
from finance.payroll.config import base_rate_code
|
||||
|
||||
|
||||
ZERO = Decimal("0")
|
||||
EXACT = "exact"
|
||||
PRELIMINARY = "preliminary"
|
||||
UNPRICED = "unpriced"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricedLabor:
|
||||
hours: Decimal
|
||||
cost: Decimal | None
|
||||
cost_status: str
|
||||
|
||||
|
||||
def _period_for(day, periods):
|
||||
candidates = [period for period in periods if period.date_from <= day <= period.date_to]
|
||||
if not candidates:
|
||||
return None
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda period: (
|
||||
(period.date_to - period.date_from).days,
|
||||
-period.date_from.toordinal(),
|
||||
-period.pk,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def labor_cost_status(exact_hours, preliminary_hours):
|
||||
if exact_hours and preliminary_hours:
|
||||
return "mixed"
|
||||
if preliminary_hours:
|
||||
return PRELIMINARY
|
||||
if exact_hours:
|
||||
return EXACT
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _attendance_hours(periods, employee_ids):
|
||||
norm_hours = defaultdict(lambda: ZERO)
|
||||
actual_hours = defaultdict(lambda: ZERO)
|
||||
if not periods:
|
||||
return norm_hours, actual_hours
|
||||
attendance = AttendanceRecord.objects.filter(
|
||||
employee_id__in=employee_ids,
|
||||
date__range=(
|
||||
min(period.date_from for period in periods),
|
||||
max(period.date_to for period in periods),
|
||||
),
|
||||
analysis_run__status=AttendanceAnalysisRun.STATUS_SUCCESS,
|
||||
).only(
|
||||
"employee_id",
|
||||
"date",
|
||||
"work_hours",
|
||||
"expected_hours",
|
||||
"effective_is_workday",
|
||||
)
|
||||
for record in attendance.iterator(chunk_size=1000):
|
||||
for period in periods:
|
||||
if not period.date_from <= record.date <= period.date_to:
|
||||
continue
|
||||
key = (period.pk, record.employee_id)
|
||||
if record.effective_is_workday and record.expected_hours:
|
||||
norm_hours[key] += Decimal(str(record.expected_hours))
|
||||
if record.work_hours and record.work_hours > 0:
|
||||
actual_hours[key] += Decimal(str(record.work_hours))
|
||||
return norm_hours, actual_hours
|
||||
|
||||
|
||||
def _payroll_statements(periods, employee_ids, currency):
|
||||
run_ids = {period.current_run_id for period in periods if period.current_run_id}
|
||||
return {
|
||||
(statement.run_id, statement.employee_id): statement
|
||||
for statement in PayrollStatement.objects.filter(
|
||||
run_id__in=run_ids,
|
||||
employee_id__in=employee_ids,
|
||||
currency=currency,
|
||||
).only("run_id", "employee_id", "gross_total")
|
||||
}
|
||||
|
||||
|
||||
def _approved_rates(employee_ids, currency, end):
|
||||
rates = defaultdict(list)
|
||||
queryset = (
|
||||
EmployeePayRate.objects.filter(
|
||||
employee_id__in=employee_ids,
|
||||
rate_code=base_rate_code(),
|
||||
status=ApprovalStatus.APPROVED,
|
||||
currency=currency,
|
||||
effective_from__lte=end,
|
||||
)
|
||||
.only("pk", "employee_id", "amount", "effective_from", "revision")
|
||||
.order_by("employee_id", "effective_from", "revision", "pk")
|
||||
)
|
||||
for rate in queryset:
|
||||
rates[rate.employee_id].append(rate)
|
||||
return rates
|
||||
|
||||
|
||||
def _preliminary_amount(rates, statement, employee_id, day):
|
||||
if statement is not None:
|
||||
return statement.gross_total
|
||||
applicable = [rate for rate in rates[employee_id] if rate.effective_from <= day]
|
||||
if not applicable:
|
||||
return None
|
||||
return max(
|
||||
applicable,
|
||||
key=lambda item: (item.effective_from, item.revision, item.pk),
|
||||
).amount
|
||||
|
||||
|
||||
def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor]:
|
||||
"""Price confirmed project hours against the employee payroll period.
|
||||
|
||||
An open period is always preliminary. Its current payroll calculation is
|
||||
used when available, otherwise the approved base rate is used. A period
|
||||
becomes exact only after closing; the immutable final gross statement then
|
||||
includes bonuses, additional earnings and payroll adjustments. Deductions
|
||||
and advance-payment mechanics do not reduce project labour cost.
|
||||
"""
|
||||
|
||||
allocations = list(allocations)
|
||||
known = [allocation for allocation in allocations if allocation.day.hours is not None]
|
||||
if not known:
|
||||
return {}
|
||||
|
||||
start = min(allocation.day.date for allocation in known)
|
||||
end = max(allocation.day.date for allocation in known)
|
||||
employee_ids = {allocation.day.employee_id for allocation in known}
|
||||
periods = list(
|
||||
PayrollPeriod.objects.filter(
|
||||
currency=currency,
|
||||
date_from__lte=end,
|
||||
date_to__gte=start,
|
||||
)
|
||||
.select_related("current_run")
|
||||
.order_by("date_from", "date_to", "pk")
|
||||
)
|
||||
period_by_allocation = {
|
||||
allocation.pk: _period_for(allocation.day.date, periods)
|
||||
for allocation in known
|
||||
}
|
||||
used_periods = {
|
||||
period.pk: period
|
||||
for period in period_by_allocation.values()
|
||||
if period is not None
|
||||
}
|
||||
|
||||
period_list = list(used_periods.values())
|
||||
norm_hours, actual_hours = _attendance_hours(period_list, employee_ids)
|
||||
statements = _payroll_statements(period_list, employee_ids, currency)
|
||||
rates = _approved_rates(employee_ids, currency, end)
|
||||
|
||||
result = {}
|
||||
for allocation in known:
|
||||
employee_id = allocation.day.employee_id
|
||||
hours = allocation.day.hours * allocation.percent / Decimal("100")
|
||||
period = period_by_allocation[allocation.pk]
|
||||
if period is None:
|
||||
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
||||
continue
|
||||
|
||||
statement = (
|
||||
statements.get((period.current_run_id, employee_id))
|
||||
if period.current_run_id
|
||||
and period.current_run.status
|
||||
not in [PayrollRunStatus.RETURNED, PayrollRunStatus.SUPERSEDED]
|
||||
else None
|
||||
)
|
||||
is_exact = (
|
||||
period.status == PayrollPeriodStatus.CLOSED
|
||||
and period.current_run is not None
|
||||
and period.current_run.status == PayrollRunStatus.PUBLISHED
|
||||
and statement is not None
|
||||
)
|
||||
if is_exact:
|
||||
denominator = actual_hours[(period.pk, employee_id)]
|
||||
if denominator <= 0:
|
||||
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
||||
else:
|
||||
result[allocation.pk] = PricedLabor(
|
||||
hours,
|
||||
hours * statement.gross_total / denominator,
|
||||
EXACT,
|
||||
)
|
||||
continue
|
||||
|
||||
# A closed period without its final published statement must remain
|
||||
# visibly unpriced. Falling back to a rate would present an estimate as
|
||||
# a final amount.
|
||||
if period.status == PayrollPeriodStatus.CLOSED:
|
||||
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
||||
continue
|
||||
|
||||
denominator = norm_hours[(period.pk, employee_id)]
|
||||
amount = _preliminary_amount(
|
||||
rates, statement, employee_id, allocation.day.date
|
||||
)
|
||||
if amount is None or denominator <= 0:
|
||||
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
||||
else:
|
||||
result[allocation.pk] = PricedLabor(
|
||||
hours,
|
||||
hours * amount / denominator,
|
||||
PRELIMINARY,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def labor_summary(allocations, currency: str) -> dict:
|
||||
allocations = list(allocations)
|
||||
priced = price_labor_allocations(allocations, currency)
|
||||
employees = defaultdict(
|
||||
lambda: {
|
||||
"hours": ZERO,
|
||||
"cost": ZERO,
|
||||
"exact_cost": ZERO,
|
||||
"preliminary_cost": ZERO,
|
||||
"exact_hours": ZERO,
|
||||
"preliminary_hours": ZERO,
|
||||
"unpriced_hours": ZERO,
|
||||
"unknown_days": 0,
|
||||
}
|
||||
)
|
||||
total_hours = ZERO
|
||||
total_cost = ZERO
|
||||
exact_cost = ZERO
|
||||
preliminary_cost = ZERO
|
||||
exact_hours = ZERO
|
||||
preliminary_hours = ZERO
|
||||
unpriced_hours = ZERO
|
||||
unknown_days = set()
|
||||
|
||||
for allocation in allocations:
|
||||
employee = allocation.day.employee
|
||||
label = employee.get_full_name().strip() or str(employee)
|
||||
row = employees[(employee.pk, label)]
|
||||
if allocation.day.hours is None:
|
||||
row["unknown_days"] += 1
|
||||
unknown_days.add(allocation.day_id)
|
||||
continue
|
||||
item = priced.get(allocation.pk)
|
||||
hours = allocation.day.hours * allocation.percent / Decimal("100")
|
||||
total_hours += hours
|
||||
row["hours"] += hours
|
||||
if item is None or item.cost is None:
|
||||
unpriced_hours += hours
|
||||
row["unpriced_hours"] += hours
|
||||
continue
|
||||
total_cost += item.cost
|
||||
row["cost"] += item.cost
|
||||
if item.cost_status == EXACT:
|
||||
exact_cost += item.cost
|
||||
exact_hours += hours
|
||||
row["exact_cost"] += item.cost
|
||||
row["exact_hours"] += hours
|
||||
else:
|
||||
preliminary_cost += item.cost
|
||||
preliminary_hours += hours
|
||||
row["preliminary_cost"] += item.cost
|
||||
row["preliminary_hours"] += hours
|
||||
|
||||
complete = not unknown_days and unpriced_hours == 0
|
||||
return {
|
||||
"labor_hours": total_hours,
|
||||
"labor_cost": total_cost,
|
||||
"exact_labor_cost": exact_cost,
|
||||
"preliminary_labor_cost": preliminary_cost,
|
||||
"exact_labor_hours": exact_hours,
|
||||
"preliminary_labor_hours": preliminary_hours,
|
||||
"labor_cost_status": labor_cost_status(exact_hours, preliminary_hours),
|
||||
"labor_hour_cost": (
|
||||
total_cost / total_hours if complete and total_hours > 0 else None
|
||||
),
|
||||
"unpriced_labor_hours": unpriced_hours,
|
||||
"unknown_hours_days": len(unknown_days),
|
||||
"labor_cost_complete": complete,
|
||||
"labor": [
|
||||
{
|
||||
"employee_id": employee_id,
|
||||
"employee": label,
|
||||
**values,
|
||||
"cost_status": labor_cost_status(
|
||||
values["exact_hours"], values["preliminary_hours"]
|
||||
),
|
||||
"cost_complete": not values["unknown_days"]
|
||||
and values["unpriced_hours"] == 0,
|
||||
"hour_cost": (
|
||||
values["cost"] / values["hours"]
|
||||
if not values["unknown_days"]
|
||||
and values["unpriced_hours"] == 0
|
||||
and values["hours"] > 0
|
||||
else None
|
||||
),
|
||||
}
|
||||
for (employee_id, label), values in sorted(
|
||||
employees.items(), key=lambda item: item[0][1]
|
||||
)
|
||||
],
|
||||
}
|
||||
@ -24,9 +24,12 @@ class ReportingEntry(models.Model):
|
||||
date = models.DateField("Дата факта", db_index=True)
|
||||
reference = models.CharField("Документ / уникальный номер", max_length=200)
|
||||
description = models.CharField("Описание", max_length=500, blank=True)
|
||||
project = models.ForeignKey(
|
||||
external_project = models.ForeignKey(
|
||||
"commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"projects.Project", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"commerce.Product", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
@ -1,12 +1,88 @@
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.models import ExternalProject, ProjectPayment
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from finance.analytics.labor import labor_cost_status, price_labor_allocations
|
||||
from finance.analytics.sales import ZERO, bounds, bucket, finish, months
|
||||
from projects.models import Project, ProjectExternalBinding, TimeAllocation
|
||||
|
||||
|
||||
def payment_queryset(start, end, currency, source=None, project=None):
|
||||
@dataclass
|
||||
class LocalProjectScope:
|
||||
projects: list[Project]
|
||||
external_to_local: dict[int, int]
|
||||
external_ids: set[int]
|
||||
selected_external_ids: set[int] | None
|
||||
include_projectless: bool
|
||||
selected_project: Project | str | None
|
||||
source_id: int | None
|
||||
|
||||
@property
|
||||
def is_filtered(self):
|
||||
return self.selected_project is not None
|
||||
|
||||
|
||||
def local_project_scope(source=None, project=None):
|
||||
external = ExternalProject.objects.all()
|
||||
bindings = ProjectExternalBinding.objects.filter(object_type="project")
|
||||
local = Project.objects.all()
|
||||
if source:
|
||||
external = external.filter(source=source)
|
||||
bindings = bindings.filter(source=source)
|
||||
local = local.filter(
|
||||
bindings__source=source, bindings__object_type="project"
|
||||
).distinct()
|
||||
|
||||
external_by_identity = {
|
||||
(row.source_id, row.external_id): row.pk for row in external.iterator()
|
||||
}
|
||||
external_to_local = {}
|
||||
for binding in bindings.iterator():
|
||||
external_id = external_by_identity.get((binding.source_id, binding.external_id))
|
||||
if external_id:
|
||||
external_to_local[external_id] = binding.project_id
|
||||
|
||||
external_ids = set(external_by_identity.values())
|
||||
selected_external_ids = None
|
||||
include_projectless = True
|
||||
if project == "unassigned":
|
||||
local = local.none()
|
||||
selected_external_ids = external_ids - set(external_to_local)
|
||||
elif project is not None:
|
||||
local = local.filter(pk=project.pk)
|
||||
selected_external_ids = {
|
||||
external_id
|
||||
for external_id, local_id in external_to_local.items()
|
||||
if local_id == project.pk
|
||||
}
|
||||
include_projectless = False
|
||||
|
||||
return LocalProjectScope(
|
||||
projects=list(local.select_related("manager").order_by("name", "pk")),
|
||||
external_to_local=external_to_local,
|
||||
external_ids=external_ids,
|
||||
selected_external_ids=selected_external_ids,
|
||||
include_projectless=include_projectless,
|
||||
selected_project=project,
|
||||
source_id=source.pk if source else None,
|
||||
)
|
||||
|
||||
|
||||
def filter_external_projects(qs, scope, field="project_id"):
|
||||
if not scope.is_filtered:
|
||||
return qs
|
||||
condition = Q(**{f"{field}__in": scope.selected_external_ids})
|
||||
if scope.include_projectless:
|
||||
condition |= Q(**{f"{field}__isnull": True})
|
||||
return qs.filter(condition)
|
||||
|
||||
|
||||
def payment_queryset(start, end, currency, source=None, project=None, scope=None):
|
||||
lower, upper = bounds(start, end)
|
||||
qs = ProjectPayment.objects.filter(
|
||||
currency=currency,
|
||||
@ -17,98 +93,293 @@ def payment_queryset(start, end, currency, source=None, project=None):
|
||||
).select_related("project", "source")
|
||||
if source:
|
||||
qs = qs.filter(source=source)
|
||||
if project is not None:
|
||||
if scope is not None:
|
||||
qs = filter_external_projects(qs, scope)
|
||||
elif project is not None:
|
||||
qs = qs.filter(project_id=None if project == "unassigned" else project.pk)
|
||||
return qs
|
||||
|
||||
|
||||
def journal_queryset(start, end, currency, source, project):
|
||||
entries = ReportingEntry.objects.filter(
|
||||
def journal_queryset(start, end, currency):
|
||||
return ReportingEntry.objects.filter(
|
||||
date__range=(start, end),
|
||||
currency=currency,
|
||||
voided_at__isnull=True,
|
||||
kind__in=["cash_out", "overhead", "purchase"],
|
||||
).select_related("project")
|
||||
if source:
|
||||
entries = entries.filter(project__source=source)
|
||||
if project is not None:
|
||||
entries = entries.filter(
|
||||
project_id=None if project == "unassigned" else project.pk
|
||||
).select_related("project", "external_project")
|
||||
|
||||
|
||||
def project_row(rows, key, name):
|
||||
if key not in rows:
|
||||
rows[key] = {
|
||||
**bucket(),
|
||||
"id": key,
|
||||
"name": name,
|
||||
"paid": ZERO,
|
||||
"payment_count": 0,
|
||||
"manual_paid": ZERO,
|
||||
"overhead": ZERO,
|
||||
"purchases": ZERO,
|
||||
"labor_hours": ZERO,
|
||||
"labor_cost": ZERO,
|
||||
"exact_labor_cost": ZERO,
|
||||
"preliminary_labor_cost": ZERO,
|
||||
"exact_labor_hours": ZERO,
|
||||
"preliminary_labor_hours": ZERO,
|
||||
"unpriced_labor_hours": ZERO,
|
||||
"unknown_hours_days": 0,
|
||||
"people_count": 0,
|
||||
"allocation_days": 0,
|
||||
"external_project_count": 0,
|
||||
"archived": False,
|
||||
}
|
||||
return rows[key]
|
||||
|
||||
|
||||
def local_target(scope, local_by_id, external_id=None, local_id=None):
|
||||
local_id = local_id or (
|
||||
scope.external_to_local.get(external_id) if external_id else None
|
||||
)
|
||||
local = local_by_id.get(local_id)
|
||||
return (
|
||||
(f"project:{local.pk}", local.name) if local else ("unassigned", "Без привязки")
|
||||
)
|
||||
|
||||
|
||||
def add_catalog(rows, scope):
|
||||
for local in scope.projects:
|
||||
row = project_row(rows, f"project:{local.pk}", local.name)
|
||||
row["archived"] = local.status == Project.Status.ARCHIVED
|
||||
row["external_project_count"] = sum(
|
||||
local_id == local.pk for local_id in scope.external_to_local.values()
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def projects_report(start, end, currency, sales, source=None, project=None):
|
||||
rows = {}
|
||||
|
||||
def row_for(key, name):
|
||||
if key not in rows:
|
||||
rows[key] = {
|
||||
**finish(bucket()),
|
||||
"id": key,
|
||||
"name": name,
|
||||
"paid": ZERO,
|
||||
"payment_count": 0,
|
||||
"manual_paid": ZERO,
|
||||
"overhead": ZERO,
|
||||
"purchases": ZERO,
|
||||
"archived": False,
|
||||
}
|
||||
return rows[key]
|
||||
|
||||
catalog = ExternalProject.objects.all()
|
||||
if source:
|
||||
catalog = catalog.filter(source=source)
|
||||
if project is not None:
|
||||
catalog = (
|
||||
catalog.none() if project == "unassigned" else catalog.filter(pk=project.pk)
|
||||
)
|
||||
for p in catalog:
|
||||
row_for(f"project:{p.pk}", p.name)["archived"] = p.archived
|
||||
def add_sales(rows, sales, scope, local_by_id):
|
||||
fields = [
|
||||
"quantity",
|
||||
"revenue",
|
||||
"cost_of_goods",
|
||||
"expenses",
|
||||
"missing_cost_lines",
|
||||
"sale_lines",
|
||||
]
|
||||
for sale_row in sales["projects"]:
|
||||
row_for(sale_row["id"], sale_row["name"]).update(sale_row)
|
||||
monthly = {
|
||||
m: {"id": m, "name": m, "paid": ZERO, "manual_paid": ZERO}
|
||||
for m in months(start, end)
|
||||
}
|
||||
categories = defaultdict(lambda: {"paid": ZERO, "payment_count": 0})
|
||||
for p in payment_queryset(start, end, currency, source, project).iterator(
|
||||
chunk_size=500
|
||||
):
|
||||
row = row_for(
|
||||
f"project:{p.project_id}" if p.project_id else "unassigned",
|
||||
p.project.name if p.project else "Без проекта",
|
||||
row_id = str(sale_row["id"])
|
||||
external_id = (
|
||||
int(row_id.split(":", 1)[1]) if row_id.startswith("project:") else None
|
||||
)
|
||||
row["paid"] += p.amount
|
||||
key, name = local_target(scope, local_by_id, external_id=external_id)
|
||||
row = project_row(rows, key, name)
|
||||
for field in fields:
|
||||
row[field] += sale_row[field]
|
||||
|
||||
|
||||
def add_payments(rows, monthly, categories, payments, scope, local_by_id):
|
||||
for payment in payments.iterator(chunk_size=500):
|
||||
key, name = local_target(scope, local_by_id, external_id=payment.project_id)
|
||||
row = project_row(rows, key, name)
|
||||
row["paid"] += payment.amount
|
||||
row["payment_count"] += 1
|
||||
monthly[timezone.localtime(p.occurred_at).strftime("%Y-%m")]["paid"] += p.amount
|
||||
category = categories[p.expense_category or "Статья не указана"]
|
||||
category["paid"] += p.amount
|
||||
month = timezone.localtime(payment.occurred_at).strftime("%Y-%m")
|
||||
monthly[month]["paid"] += payment.amount
|
||||
category = categories[payment.expense_category or "Статья не указана"]
|
||||
category["paid"] += payment.amount
|
||||
category["payment_count"] += 1
|
||||
for entry in journal_queryset(start, end, currency, source, project):
|
||||
row = row_for(
|
||||
f"project:{entry.project_id}" if entry.project_id else "unassigned",
|
||||
entry.project.name if entry.project else "Без проекта",
|
||||
)
|
||||
field = {
|
||||
"cash_out": "manual_paid",
|
||||
"overhead": "overhead",
|
||||
"purchase": "purchases",
|
||||
}[entry.kind]
|
||||
row[field] += entry.amount
|
||||
|
||||
|
||||
def journal_local_id(entry, scope):
|
||||
return entry.project_id or (
|
||||
scope.external_to_local.get(entry.external_project_id)
|
||||
if entry.external_project_id
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def journal_entry_in_scope(local_id, project, scope, local_by_id):
|
||||
if project == "unassigned":
|
||||
return not local_id
|
||||
if project is not None:
|
||||
return local_id == project.pk
|
||||
return not scope.source_id or local_id in local_by_id
|
||||
|
||||
|
||||
def add_journal(rows, monthly, entries, project, scope, local_by_id):
|
||||
fields = {
|
||||
"cash_out": "manual_paid",
|
||||
"overhead": "overhead",
|
||||
"purchase": "purchases",
|
||||
}
|
||||
for entry in entries:
|
||||
local_id = journal_local_id(entry, scope)
|
||||
if not journal_entry_in_scope(local_id, project, scope, local_by_id):
|
||||
continue
|
||||
key, name = local_target(scope, local_by_id, local_id=local_id)
|
||||
row = project_row(rows, key, name)
|
||||
row[fields[entry.kind]] += entry.amount
|
||||
if entry.kind == "cash_out":
|
||||
monthly[entry.date.strftime("%Y-%m")]["manual_paid"] += entry.amount
|
||||
summary = {
|
||||
field: sum((r[field] for r in rows.values()), ZERO)
|
||||
for field in ["paid", "manual_paid", "overhead", "purchases", "payment_count"]
|
||||
|
||||
|
||||
def add_allocations(rows, monthly, allocations, currency):
|
||||
allocations = list(allocations)
|
||||
priced = price_labor_allocations(allocations, currency)
|
||||
row_days = defaultdict(set)
|
||||
row_people = defaultdict(set)
|
||||
all_days = set()
|
||||
all_people = set()
|
||||
for allocation in allocations:
|
||||
key = f"project:{allocation.project_id}"
|
||||
row = project_row(rows, key, allocation.project.name)
|
||||
row_days[key].add(allocation.day_id)
|
||||
row_people[key].add(allocation.day.employee_id)
|
||||
all_days.add(allocation.day_id)
|
||||
all_people.add(allocation.day.employee_id)
|
||||
if allocation.day.hours is None:
|
||||
row["unknown_hours_days"] += 1
|
||||
continue
|
||||
hours = allocation.day.hours * allocation.percent / Decimal("100")
|
||||
row["labor_hours"] += hours
|
||||
monthly[allocation.day.date.strftime("%Y-%m")]["labor_hours"] += hours
|
||||
labor = priced.get(allocation.pk)
|
||||
if labor is None or labor.cost is None:
|
||||
row["unpriced_labor_hours"] += hours
|
||||
monthly[allocation.day.date.strftime("%Y-%m")][
|
||||
"unpriced_labor_hours"
|
||||
] += hours
|
||||
else:
|
||||
row["labor_cost"] += labor.cost
|
||||
monthly[allocation.day.date.strftime("%Y-%m")]["labor_cost"] += labor.cost
|
||||
if labor.cost_status == "exact":
|
||||
row["exact_labor_cost"] += labor.cost
|
||||
row["exact_labor_hours"] += hours
|
||||
monthly[allocation.day.date.strftime("%Y-%m")][
|
||||
"exact_labor_cost"
|
||||
] += labor.cost
|
||||
monthly[allocation.day.date.strftime("%Y-%m")][
|
||||
"exact_labor_hours"
|
||||
] += hours
|
||||
else:
|
||||
row["preliminary_labor_cost"] += labor.cost
|
||||
row["preliminary_labor_hours"] += hours
|
||||
monthly[allocation.day.date.strftime("%Y-%m")][
|
||||
"preliminary_labor_cost"
|
||||
] += labor.cost
|
||||
monthly[allocation.day.date.strftime("%Y-%m")][
|
||||
"preliminary_labor_hours"
|
||||
] += hours
|
||||
for key, row in rows.items():
|
||||
row["people_count"] = len(row_people[key])
|
||||
row["allocation_days"] = len(row_days[key])
|
||||
row.update(finish(row))
|
||||
row["labor_cost_complete"] = not (
|
||||
row["unknown_hours_days"] or row["unpriced_labor_hours"]
|
||||
)
|
||||
row["labor_cost_status"] = labor_cost_status(
|
||||
row["exact_labor_hours"], row["preliminary_labor_hours"]
|
||||
)
|
||||
row["labor_hour_cost"] = (
|
||||
row["labor_cost"] / row["labor_hours"]
|
||||
if row["labor_cost_complete"] and row["labor_hours"] > 0
|
||||
else None
|
||||
)
|
||||
row["project_result"] = (
|
||||
row["profit"] - row["overhead"] - row["labor_cost"]
|
||||
if row["profit"] is not None and row["labor_cost_complete"]
|
||||
else None
|
||||
)
|
||||
for month in monthly.values():
|
||||
month["labor_cost_status"] = labor_cost_status(
|
||||
month["exact_labor_hours"], month["preliminary_labor_hours"]
|
||||
)
|
||||
return all_people, all_days
|
||||
|
||||
|
||||
def projects_report(start, end, currency, sales, source=None, project=None, scope=None):
|
||||
scope = scope or local_project_scope(source, project)
|
||||
local_by_id = {item.pk: item for item in scope.projects}
|
||||
rows = {}
|
||||
monthly = {
|
||||
month: {
|
||||
"id": month,
|
||||
"name": month,
|
||||
"paid": ZERO,
|
||||
"manual_paid": ZERO,
|
||||
"labor_hours": ZERO,
|
||||
"labor_cost": ZERO,
|
||||
"exact_labor_cost": ZERO,
|
||||
"preliminary_labor_cost": ZERO,
|
||||
"exact_labor_hours": ZERO,
|
||||
"preliminary_labor_hours": ZERO,
|
||||
"unpriced_labor_hours": ZERO,
|
||||
}
|
||||
for month in months(start, end)
|
||||
}
|
||||
summary["unassigned_paid"] = rows.get("unassigned", {}).get("paid", ZERO)
|
||||
summary["project_count"] = catalog.count()
|
||||
categories = defaultdict(lambda: {"paid": ZERO, "payment_count": 0})
|
||||
|
||||
add_catalog(rows, scope)
|
||||
add_sales(rows, sales, scope, local_by_id)
|
||||
payments = payment_queryset(start, end, currency, source, scope=scope)
|
||||
add_payments(rows, monthly, categories, payments, scope, local_by_id)
|
||||
add_journal(
|
||||
rows,
|
||||
monthly,
|
||||
journal_queryset(start, end, currency),
|
||||
project,
|
||||
scope,
|
||||
local_by_id,
|
||||
)
|
||||
allocations = TimeAllocation.objects.filter(
|
||||
day__date__range=(start, end),
|
||||
day__status="confirmed",
|
||||
project__in=scope.projects,
|
||||
).select_related("day", "project")
|
||||
all_people, all_days = add_allocations(rows, monthly, allocations, currency)
|
||||
|
||||
summary_fields = [
|
||||
"paid",
|
||||
"manual_paid",
|
||||
"overhead",
|
||||
"purchases",
|
||||
"payment_count",
|
||||
"labor_hours",
|
||||
"labor_cost",
|
||||
"exact_labor_cost",
|
||||
"preliminary_labor_cost",
|
||||
"exact_labor_hours",
|
||||
"preliminary_labor_hours",
|
||||
"unpriced_labor_hours",
|
||||
"unknown_hours_days",
|
||||
]
|
||||
summary = {
|
||||
field: sum((row[field] for row in rows.values()), ZERO)
|
||||
for field in summary_fields
|
||||
}
|
||||
labor_cost_complete = not any(
|
||||
row["unknown_hours_days"] or row["unpriced_labor_hours"]
|
||||
for row in rows.values()
|
||||
)
|
||||
summary.update(
|
||||
unassigned_paid=rows.get("unassigned", {}).get("paid", ZERO),
|
||||
project_count=len(scope.projects),
|
||||
people_count=len(all_people),
|
||||
allocation_days=len(all_days),
|
||||
labor_cost_complete=labor_cost_complete,
|
||||
labor_cost_status=labor_cost_status(
|
||||
summary["exact_labor_hours"], summary["preliminary_labor_hours"]
|
||||
),
|
||||
labor_hour_cost=(
|
||||
summary["labor_cost"] / summary["labor_hours"]
|
||||
if labor_cost_complete and summary["labor_hours"] > 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
return {
|
||||
"summary": summary,
|
||||
"rows": sorted(rows.values(), key=lambda r: (-r["paid"], r["name"])),
|
||||
"rows": sorted(rows.values(), key=lambda row: (-row["paid"], row["name"])),
|
||||
"monthly": list(monthly.values()),
|
||||
"categories": [
|
||||
{"id": k, "name": k, **v} for k, v in sorted(categories.items())
|
||||
{"id": key, "name": key, **value}
|
||||
for key, value in sorted(categories.items())
|
||||
],
|
||||
}
|
||||
|
||||
@ -76,7 +76,34 @@ def project_costs(costs, project_id):
|
||||
)
|
||||
|
||||
|
||||
def sales_report(start, end, currency, source=None, product_ids=None, project=None):
|
||||
def filter_project_records(lines, costs, project, project_scope):
|
||||
if project_scope is not None and project_scope.is_filtered:
|
||||
line_filter = Q(sale__project_id__in=project_scope.selected_external_ids)
|
||||
cost_filter = Q(project_id__in=project_scope.selected_external_ids) | Q(
|
||||
project__isnull=True,
|
||||
sale_line__sale__project_id__in=project_scope.selected_external_ids,
|
||||
)
|
||||
if project_scope.include_projectless:
|
||||
line_filter |= Q(sale__project__isnull=True)
|
||||
cost_filter |= Q(
|
||||
project__isnull=True, sale_line__sale__project__isnull=True
|
||||
)
|
||||
return lines.filter(line_filter), costs.filter(cost_filter)
|
||||
if project is None:
|
||||
return lines, costs
|
||||
project_id = None if project == "unassigned" else project.pk
|
||||
return lines.filter(sale__project_id=project_id), project_costs(costs, project_id)
|
||||
|
||||
|
||||
def sales_report(
|
||||
start,
|
||||
end,
|
||||
currency,
|
||||
source=None,
|
||||
product_ids=None,
|
||||
project=None,
|
||||
project_scope=None,
|
||||
):
|
||||
lower, upper = bounds(start, end)
|
||||
lines = SaleLine.objects.filter(
|
||||
sale__counts_as_sale=True,
|
||||
@ -101,10 +128,7 @@ def sales_report(start, end, currency, source=None, product_ids=None, project=No
|
||||
lines = lines.filter(sale__source_id=source)
|
||||
costs = costs.filter(source_id=source)
|
||||
|
||||
if project is not None:
|
||||
project_id = None if project == "unassigned" else project.pk
|
||||
lines = lines.filter(sale__project_id=project_id)
|
||||
costs = project_costs(costs, project_id)
|
||||
lines, costs = filter_project_records(lines, costs, project, project_scope)
|
||||
|
||||
if product_ids is not None:
|
||||
lines = lines.filter(product_id__in=product_ids)
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def bind_existing_entries(apps, schema_editor):
|
||||
ReportingEntry = apps.get_model("finance", "ReportingEntry")
|
||||
Binding = apps.get_model("projects", "ProjectExternalBinding")
|
||||
bindings = {
|
||||
(binding.source_id, binding.external_id): binding.project_id
|
||||
for binding in Binding.objects.filter(object_type="project").iterator()
|
||||
}
|
||||
for entry in ReportingEntry.objects.exclude(
|
||||
external_project_id=None
|
||||
).select_related("external_project"):
|
||||
entry.project_id = bindings.get(
|
||||
(entry.external_project.source_id, entry.external_project.external_id)
|
||||
)
|
||||
if entry.project_id:
|
||||
entry.save(update_fields=["project"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0033_reportingentry_project"),
|
||||
("projects", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="reportingentry",
|
||||
old_name="project",
|
||||
new_name="external_project",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reportingentry",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(bind_existing_entries, migrations.RunPython.noop),
|
||||
]
|
||||
119
backend/projects/README.md
Normal file
119
backend/projects/README.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Локальные проекты и участие сотрудников
|
||||
|
||||
`projects` — самостоятельный Django-модуль корпоративного портала. Проект создаётся
|
||||
локально и не требует подключения МойСклад. Торговые модели и действующая
|
||||
синхронизация остаются в `commerce`.
|
||||
|
||||
## Структура
|
||||
|
||||
- `models/project.py`: карточка проекта, история настроек и связанные материалы.
|
||||
- `models/membership.py`: участники, роли, даты участия и плановая загрузка.
|
||||
- `models/stage.py`: этапы и сроки.
|
||||
- `models/external_binding.py`: связь локального проекта с внешним объектом.
|
||||
- `models/time_allocation.py`: день сотрудника, распределение, версии и напоминания.
|
||||
- `services/time_tracking.py`: валидация распределения, снимок часов, напоминания.
|
||||
- `permissions.py`: доступ к проектам и управлению ими.
|
||||
- `api/v1/projects/`: API; `frontend/src/app/projects/`: список и карточка.
|
||||
|
||||
## Доступ и история
|
||||
|
||||
Создание доступно администраторам и пользователям с `projects.add_project`.
|
||||
Руководитель управляет своей карточкой, командой, этапами, связями и видит отчёт
|
||||
команды. Участник видит проект и собственные строки времени. Администратор видит
|
||||
все проекты. `?mine=true` ограничивает список собственными назначениями и для
|
||||
администратора. Финансовый endpoint отдельно проверяет действующие права
|
||||
`can_access_payroll_management`; руководство проектом не открывает зарплаты.
|
||||
Все ответы API имеют `Cache-Control: private, no-store`.
|
||||
|
||||
Проекты архивируются, API не удаляет их. Участие завершается датой окончания,
|
||||
чтобы сохранить контекст. API сохраняет события изменений карточки и связей;
|
||||
версии распределения времени хранят полный снимок процентов, часов, комментария
|
||||
и статуса. В админке дни и версии доступны только для чтения.
|
||||
|
||||
## Внешние связи
|
||||
|
||||
Идентичность объекта — `(source, object_type, external_id)`. Один внешний объект
|
||||
может принадлежать одному локальному проекту; локальный проект может объединять
|
||||
несколько внешних объектов. Названия не служат ключом и автоматически не
|
||||
объединяются. Привязка не изменяет внешнюю запись, перенос привязки требует явного
|
||||
удаления прежней связи. Синхронизация `commerce` не записывает локальные проекты.
|
||||
|
||||
Для проектов МойСклад есть поиск по импортированному справочнику, включая
|
||||
индикацию занятых привязок. Главная страница `/projects` отдельно показывает
|
||||
активные внешние проекты без связи. Из этой очереди руководитель может одной
|
||||
операцией создать локальную карточку вместе с привязкой либо связать внешний
|
||||
проект с уже существующей карточкой. Создание выполняется транзакционно и не
|
||||
изменяет исходную запись МойСклад. Для будущих источников можно сохранять
|
||||
привязки по ID и типу объекта. Это хранение соответствий, а не реализация новых
|
||||
адаптеров.
|
||||
|
||||
Финансовая вкладка карточки и корпоративная аналитика выбирают проведённые, не
|
||||
удалённые из источника исходящие платежи по точным связанным идентичностям и
|
||||
одной валюте. Корпоративный отчёт использует локальную карточку как основную
|
||||
строку, объединяет в ней продажи, расходы и платежи всех привязанных объектов и
|
||||
показывает неподключённые данные в группе «Без привязки». Подтверждённое
|
||||
распределение сотрудников добавляется в часы, дни учёта и число участников.
|
||||
Движение денег, расходы продаж и трудозатраты остаются отдельными показателями.
|
||||
|
||||
## Ежедневное распределение
|
||||
|
||||
`/projects`, профиль и ежедневная «Выработка» открывают один модал. В нём есть
|
||||
дата, проекты сотрудника, общая деятельность (работа отдела, обучение, поддержка),
|
||||
проценты и комментарий. Подтверждение требует ровно 100%; черновик допускает
|
||||
меньшую сумму. Повторяющиеся строки и сумма больше 100% отклоняются. Повторение
|
||||
прошлого подтверждённого дня за 90 дней фильтрует недоступные назначения и требует
|
||||
ручного подтверждения нового дня. Можно редактировать дни за прошедшие 366 дней.
|
||||
|
||||
Доступные назначения учитывают даты участия. Новые записи не относятся к
|
||||
завершённым и архивным проектам. Ранее сохранённые строки можно исправлять с
|
||||
сохранением версии. Продолжительность берётся из успешного анализа посещаемости
|
||||
с положительным `work_hours`. Без него сохраняются проценты, часы остаются `null`.
|
||||
Это часы по данным посещаемости, не автоматически подтверждённый трудозатратный
|
||||
табель. Изменение посещаемости после сохранения отмечается при открытии дня;
|
||||
повторное сохранение создаёт новую версию с обновлёнными часами.
|
||||
|
||||
Часы участия = часы конкретного дня × процент / 100. В итог входят только
|
||||
подтверждённые дни с известными часами; черновики и дни без часов отмечены отдельно.
|
||||
Средние дневные проценты не используются вместо суммы часов. Часы и баллы
|
||||
выработки не смешиваются, зарплата от ввода процентов не изменяется.
|
||||
|
||||
Сохранение блокирует строку сотрудника, проверяет версию и атомарно заменяет
|
||||
строки дня вместе со снимком истории. Для конкурентного доступа предусмотрен
|
||||
PostgreSQL; SQLite в локальной разработке не реализует `SELECT FOR UPDATE`.
|
||||
|
||||
## Напоминания
|
||||
|
||||
Общая оболочка портала проверяет напоминание после входа и раз в минуту, когда
|
||||
вкладка видима, другое окно не открыто и пользователь не вводит текст. Сервер
|
||||
выбирает последний незаполненный день за 14 дней, для которого есть успешная
|
||||
посещаемость с положительными часами и доступный проект. Используются только
|
||||
прошедшие даты, поэтому сегодняшняя/ночная смена не предлагается до завершения
|
||||
календарного дня. Выходные и отсутствия не выводятся из предположения о пятидневке.
|
||||
|
||||
Право показа резервируется на сервере один раз за день, общим для устройств.
|
||||
«Напомнить через 2 часа» откладывает следующую попытку; закрытие оставляет день
|
||||
незаполненным, доступным из «Мой день». Нужна открытая вкладка портала: отдельных
|
||||
email/push-рассылок эта версия не отправляет. Отключение напоминаний конкретному
|
||||
сотруднику доступно в админке `AllocationReminder.enabled`.
|
||||
|
||||
## Связи с другими разделами и границы версии
|
||||
|
||||
Задачи, закупки и документы присоединяются внутренними ссылками из карточки
|
||||
проекта. Доступ к оригиналу проверяется его модулем. Пока это явные ссылки, а не
|
||||
обязательное поле проекта в формах создания каждого из этих объектов.
|
||||
|
||||
Плановые денежные бюджеты, распределение зарплаты и общих затрат, согласование
|
||||
трудозатрат руководителем и закрытие финансовых периодов этой версией не вводятся.
|
||||
Они требуют отдельных правил расчёта и будут строиться поверх сохранённого
|
||||
участия. Исходная книга Excel не импортируется этим модулем.
|
||||
|
||||
## Проверка
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
.venv/bin/python manage.py migrate
|
||||
.venv/bin/python manage.py check
|
||||
.venv/bin/python -m pytest tests/projects tests/finance/test_projects.py -q
|
||||
```
|
||||
|
||||
Frontend: `npm run build`, `npx tsc --noEmit`, целевой ESLint для новых компонентов.
|
||||
0
backend/projects/__init__.py
Normal file
0
backend/projects/__init__.py
Normal file
52
backend/projects/admin.py
Normal file
52
backend/projects/admin.py
Normal file
@ -0,0 +1,52 @@
|
||||
from django.contrib import admin
|
||||
from .models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
ProjectStage,
|
||||
ProjectExternalBinding,
|
||||
ProjectEvent,
|
||||
DailyAllocation,
|
||||
AllocationRevision,
|
||||
AllocationReminder,
|
||||
)
|
||||
|
||||
|
||||
class MembershipInline(admin.TabularInline):
|
||||
model = ProjectMembership
|
||||
extra = 0
|
||||
|
||||
|
||||
class StageInline(admin.TabularInline):
|
||||
model = ProjectStage
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "manager", "status", "starts_on", "ends_on"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["name", "customer"]
|
||||
inlines = [MembershipInline, StageInline]
|
||||
|
||||
|
||||
@admin.register(ProjectExternalBinding)
|
||||
class BindingAdmin(admin.ModelAdmin):
|
||||
list_display = ["project", "source", "external_name", "external_id"]
|
||||
search_fields = ["project__name", "external_name", "external_id"]
|
||||
|
||||
|
||||
class ReadOnlyAdmin(admin.ModelAdmin):
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(ProjectEvent, ReadOnlyAdmin)
|
||||
admin.site.register(DailyAllocation, ReadOnlyAdmin)
|
||||
admin.site.register(AllocationRevision, ReadOnlyAdmin)
|
||||
admin.site.register(AllocationReminder)
|
||||
7
backend/projects/apps.py
Normal file
7
backend/projects/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "projects"
|
||||
verbose_name = "Проекты"
|
||||
478
backend/projects/migrations/0001_initial.py
Normal file
478
backend/projects/migrations/0001_initial.py
Normal file
@ -0,0 +1,478 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 09:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("attendance", "0006_attendanceautosyncsettings"),
|
||||
("commerce", "0014_externalproject_projectpayment_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AllocationReminder",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("last_claim_date", models.DateField(null=True)),
|
||||
("snoozed_until", models.DateTimeField(null=True)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
(
|
||||
"employee",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DailyAllocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date", models.DateField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[("draft", "Черновик"), ("confirmed", "Подтверждено")],
|
||||
default="draft",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"hours",
|
||||
models.DecimalField(
|
||||
blank=True, decimal_places=3, max_digits=6, null=True
|
||||
),
|
||||
),
|
||||
("hours_source", models.CharField(default="unknown", max_length=30)),
|
||||
("note", models.TextField(blank=True)),
|
||||
("version", models.PositiveIntegerField(default=0)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"attendance_record",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="attendance.attendancerecord",
|
||||
),
|
||||
),
|
||||
(
|
||||
"employee",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="project_days",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AllocationRevision",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("snapshot", models.JSONField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"day",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="revisions",
|
||||
to="projects.dailyallocation",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Project",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="Название")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Цель и описание"),
|
||||
),
|
||||
(
|
||||
"customer",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Заказчик"
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("planned", "Планируется"),
|
||||
("active", "В работе"),
|
||||
("paused", "Приостановлен"),
|
||||
("completed", "Завершён"),
|
||||
("archived", "Архив"),
|
||||
],
|
||||
default="planned",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("starts_on", models.DateField(blank=True, null=True)),
|
||||
("ends_on", models.DateField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="created_projects",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"manager",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="managed_projects",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Локальный проект",
|
||||
"verbose_name_plural": "Локальные проекты",
|
||||
"ordering": ["name", "pk"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("action", models.CharField(max_length=100)),
|
||||
("details", models.JSONField(default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="events",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at", "-id"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectExternalBinding",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("external_id", models.CharField(max_length=255)),
|
||||
("object_type", models.CharField(default="project", max_length=50)),
|
||||
("external_name", models.CharField(blank=True, max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="bindings",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="commerce.sourcesystem",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectMembership",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("role", models.CharField(default="Участник", max_length=100)),
|
||||
(
|
||||
"planned_percent",
|
||||
models.DecimalField(decimal_places=2, default=0, max_digits=5),
|
||||
),
|
||||
("starts_on", models.DateField(blank=True, null=True)),
|
||||
("ends_on", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"employee",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="project_memberships",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectResource",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
(
|
||||
"kind",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("task", "Задача"),
|
||||
("procurement", "Закупка"),
|
||||
("document", "Документ"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("url", models.CharField(max_length=1000)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="resources",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProjectStage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("due_on", models.DateField(blank=True, null=True)),
|
||||
("done", models.BooleanField(default=False)),
|
||||
("position", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stages",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["position", "id"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TimeAllocation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"activity",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Проект"),
|
||||
("department", "Работа отдела"),
|
||||
("training", "Обучение"),
|
||||
("support", "Поддержка"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("percent", models.DecimalField(decimal_places=2, max_digits=5)),
|
||||
(
|
||||
"day",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="allocations",
|
||||
to="projects.dailyallocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="time_allocations",
|
||||
to="projects.project",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="dailyallocation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("employee", "date"), name="project_day_unique"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="project",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("ends_on__isnull", True),
|
||||
("starts_on__isnull", True),
|
||||
("ends_on__gte", models.F("starts_on")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="project_dates_order",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectexternalbinding",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("source", "object_type", "external_id"),
|
||||
name="project_external_identity",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectmembership",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("project", "employee"), name="project_member_unique"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectmembership",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("planned_percent__gte", 0), ("planned_percent__lte", 100)
|
||||
),
|
||||
name="project_member_percent",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectmembership",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("ends_on__isnull", True),
|
||||
("starts_on__isnull", True),
|
||||
("ends_on__gte", models.F("starts_on")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="project_member_dates",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timeallocation",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("percent__gt", 0), ("percent__lte", 100)),
|
||||
name="project_allocation_percent",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timeallocation",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(("activity", ""), ("project__isnull", False)),
|
||||
models.Q(
|
||||
("activity__in", ["department", "training", "support"]),
|
||||
("project__isnull", True),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="project_allocation_target",
|
||||
),
|
||||
),
|
||||
]
|
||||
31
backend/projects/migrations/0002_projectboardlink.py
Normal file
31
backend/projects/migrations/0002_projectboardlink.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 12:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0001_initial'),
|
||||
('tasks', '0042_alter_taskactivity_object_kind_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProjectBoardLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='tasks.taskboard')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_project_board_links', to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='board_links', to='projects.project')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['board__name', 'board_id'],
|
||||
'constraints': [models.UniqueConstraint(fields=('project', 'board'), name='project_board_link_unique')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/projects/migrations/__init__.py
Normal file
0
backend/projects/migrations/__init__.py
Normal file
24
backend/projects/models/__init__.py
Normal file
24
backend/projects/models/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
from .project import Project, ProjectBoardLink, ProjectEvent, ProjectResource
|
||||
from .membership import ProjectMembership
|
||||
from .stage import ProjectStage
|
||||
from .external_binding import ProjectExternalBinding
|
||||
from .time_allocation import (
|
||||
DailyAllocation,
|
||||
TimeAllocation,
|
||||
AllocationRevision,
|
||||
AllocationReminder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Project",
|
||||
"ProjectEvent",
|
||||
"ProjectResource",
|
||||
"ProjectBoardLink",
|
||||
"ProjectMembership",
|
||||
"ProjectStage",
|
||||
"ProjectExternalBinding",
|
||||
"DailyAllocation",
|
||||
"TimeAllocation",
|
||||
"AllocationRevision",
|
||||
"AllocationReminder",
|
||||
]
|
||||
20
backend/projects/models/external_binding.py
Normal file
20
backend/projects/models/external_binding.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ProjectExternalBinding(models.Model):
|
||||
project = models.ForeignKey(
|
||||
"projects.Project", on_delete=models.PROTECT, related_name="bindings"
|
||||
)
|
||||
source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT)
|
||||
external_id = models.CharField(max_length=255)
|
||||
object_type = models.CharField(max_length=50, default="project")
|
||||
external_name = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["source", "object_type", "external_id"],
|
||||
name="project_external_identity",
|
||||
)
|
||||
]
|
||||
34
backend/projects/models/membership.py
Normal file
34
backend/projects/models/membership.py
Normal file
@ -0,0 +1,34 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ProjectMembership(models.Model):
|
||||
project = models.ForeignKey(
|
||||
"projects.Project", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="project_memberships",
|
||||
)
|
||||
role = models.CharField(max_length=100, default="Участник")
|
||||
planned_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
starts_on = models.DateField(null=True, blank=True)
|
||||
ends_on = models.DateField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "employee"], name="project_member_unique"
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(planned_percent__gte=0, planned_percent__lte=100),
|
||||
name="project_member_percent",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(ends_on__isnull=True)
|
||||
| models.Q(starts_on__isnull=True)
|
||||
| models.Q(ends_on__gte=models.F("starts_on")),
|
||||
name="project_member_dates",
|
||||
),
|
||||
]
|
||||
101
backend/projects/models/project.py
Normal file
101
backend/projects/models/project.py
Normal file
@ -0,0 +1,101 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
PLANNED = "planned", "Планируется"
|
||||
ACTIVE = "active", "В работе"
|
||||
PAUSED = "paused", "Приостановлен"
|
||||
COMPLETED = "completed", "Завершён"
|
||||
ARCHIVED = "archived", "Архив"
|
||||
|
||||
name = models.CharField("Название", max_length=255)
|
||||
description = models.TextField("Цель и описание", blank=True)
|
||||
customer = models.CharField("Заказчик", max_length=255, blank=True)
|
||||
manager = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="managed_projects",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PLANNED
|
||||
)
|
||||
starts_on = models.DateField(null=True, blank=True)
|
||||
ends_on = models.DateField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_projects",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name", "pk"]
|
||||
verbose_name = "Локальный проект"
|
||||
verbose_name_plural = "Локальные проекты"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(ends_on__isnull=True)
|
||||
| models.Q(starts_on__isnull=True)
|
||||
| models.Q(ends_on__gte=models.F("starts_on")),
|
||||
name="project_dates_order",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProjectEvent(models.Model):
|
||||
project = models.ForeignKey(
|
||||
Project, on_delete=models.PROTECT, related_name="events"
|
||||
)
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
action = models.CharField(max_length=100)
|
||||
details = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at", "-id"]
|
||||
|
||||
|
||||
class ProjectResource(models.Model):
|
||||
project = models.ForeignKey(
|
||||
Project, on_delete=models.CASCADE, related_name="resources"
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
kind = models.CharField(
|
||||
max_length=16,
|
||||
choices=[
|
||||
("task", "Задача"),
|
||||
("procurement", "Закупка"),
|
||||
("document", "Документ"),
|
||||
],
|
||||
)
|
||||
url = models.CharField(max_length=1000)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class ProjectBoardLink(models.Model):
|
||||
project = models.ForeignKey(
|
||||
Project, on_delete=models.CASCADE, related_name="board_links"
|
||||
)
|
||||
board = models.ForeignKey(
|
||||
"tasks.TaskBoard", on_delete=models.CASCADE, related_name="project_links"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="created_project_board_links",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["board__name", "board_id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "board"], name="project_board_link_unique"
|
||||
)
|
||||
]
|
||||
14
backend/projects/models/stage.py
Normal file
14
backend/projects/models/stage.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ProjectStage(models.Model):
|
||||
project = models.ForeignKey(
|
||||
"projects.Project", on_delete=models.CASCADE, related_name="stages"
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
due_on = models.DateField(null=True, blank=True)
|
||||
done = models.BooleanField(default=False)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["position", "id"]
|
||||
88
backend/projects/models/time_allocation.py
Normal file
88
backend/projects/models/time_allocation.py
Normal file
@ -0,0 +1,88 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DailyAllocation(models.Model):
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="project_days"
|
||||
)
|
||||
date = models.DateField()
|
||||
status = models.CharField(
|
||||
max_length=12,
|
||||
choices=[("draft", "Черновик"), ("confirmed", "Подтверждено")],
|
||||
default="draft",
|
||||
)
|
||||
hours = models.DecimalField(max_digits=6, decimal_places=3, null=True, blank=True)
|
||||
hours_source = models.CharField(max_length=30, default="unknown")
|
||||
attendance_record = models.ForeignKey(
|
||||
"attendance.AttendanceRecord", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
note = models.TextField(blank=True)
|
||||
version = models.PositiveIntegerField(default=0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["employee", "date"], name="project_day_unique"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class TimeAllocation(models.Model):
|
||||
day = models.ForeignKey(
|
||||
DailyAllocation, on_delete=models.CASCADE, related_name="allocations"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"projects.Project",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="time_allocations",
|
||||
)
|
||||
activity = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Проект"),
|
||||
("department", "Работа отдела"),
|
||||
("training", "Обучение"),
|
||||
("support", "Поддержка"),
|
||||
],
|
||||
)
|
||||
percent = models.DecimalField(max_digits=5, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(percent__gt=0, percent__lte=100),
|
||||
name="project_allocation_percent",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
models.Q(project__isnull=False, activity="")
|
||||
| models.Q(
|
||||
project__isnull=True,
|
||||
activity__in=["department", "training", "support"],
|
||||
)
|
||||
),
|
||||
name="project_allocation_target",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class AllocationRevision(models.Model):
|
||||
day = models.ForeignKey(
|
||||
DailyAllocation, on_delete=models.PROTECT, related_name="revisions"
|
||||
)
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
snapshot = models.JSONField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class AllocationReminder(models.Model):
|
||||
employee = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
last_claim_date = models.DateField(null=True)
|
||||
snoozed_until = models.DateTimeField(null=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
17
backend/projects/permissions.py
Normal file
17
backend/projects/permissions.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.db.models import Q
|
||||
from .models import Project
|
||||
|
||||
|
||||
def can_create(user):
|
||||
return user.is_staff or user.has_perm("projects.add_project")
|
||||
|
||||
|
||||
def can_manage(user, project):
|
||||
return user.is_staff or user.is_superuser or project.manager_id == user.pk
|
||||
|
||||
|
||||
def visible_projects(user):
|
||||
qs = Project.objects.all()
|
||||
if user.is_staff or user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(Q(manager=user) | Q(memberships__employee=user)).distinct()
|
||||
0
backend/projects/services/__init__.py
Normal file
0
backend/projects/services/__init__.py
Normal file
195
backend/projects/services/time_tracking.py
Normal file
195
backend/projects/services/time_tracking.py
Normal file
@ -0,0 +1,195 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from attendance.models import AttendanceRecord
|
||||
from projects.models import (
|
||||
DailyAllocation,
|
||||
TimeAllocation,
|
||||
AllocationRevision,
|
||||
Project,
|
||||
AllocationReminder,
|
||||
)
|
||||
|
||||
|
||||
def available_projects(user, day):
|
||||
return (
|
||||
Project.objects.filter(status__in=["planned", "active", "paused"])
|
||||
.filter(
|
||||
Q(manager=user)
|
||||
| (
|
||||
Q(memberships__employee=user)
|
||||
& (
|
||||
Q(memberships__starts_on__isnull=True)
|
||||
| Q(memberships__starts_on__lte=day)
|
||||
)
|
||||
& (
|
||||
Q(memberships__ends_on__isnull=True)
|
||||
| Q(memberships__ends_on__gte=day)
|
||||
)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def attendance_for(user, day):
|
||||
return AttendanceRecord.objects.filter(
|
||||
employee=user, date=day, analysis_run__status="success", work_hours__gt=0
|
||||
).first()
|
||||
|
||||
|
||||
def hours_for(record):
|
||||
return (
|
||||
Decimal(str(record.work_hours)).quantize(Decimal("0.001")) if record else None
|
||||
)
|
||||
|
||||
|
||||
def day_data(user, day):
|
||||
entry = (
|
||||
DailyAllocation.objects.filter(employee=user, date=day)
|
||||
.prefetch_related("allocations__project")
|
||||
.first()
|
||||
)
|
||||
record = attendance_for(user, day)
|
||||
return {
|
||||
"date": day,
|
||||
"status": entry.status if entry else "draft",
|
||||
"version": entry.version if entry else 0,
|
||||
"hours": entry.hours if entry else hours_for(record),
|
||||
"hours_source": entry.hours_source
|
||||
if entry
|
||||
else ("attendance" if record else "unknown"),
|
||||
"attendance_changed": bool(entry and entry.hours != hours_for(record)),
|
||||
"note": entry.note if entry else "",
|
||||
"allocations": [
|
||||
{
|
||||
"project": a.project_id,
|
||||
"project_name": a.project.name if a.project else "",
|
||||
"activity": a.activity,
|
||||
"percent": str(a.percent),
|
||||
}
|
||||
for a in entry.allocations.all()
|
||||
]
|
||||
if entry
|
||||
else [],
|
||||
"projects": list(available_projects(user, day).values("id", "name")),
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_day(user, data):
|
||||
# Lock the owner as well as existing days: first saves must serialize too.
|
||||
get_user_model().objects.select_for_update().get(pk=user.pk)
|
||||
day = data["date"]
|
||||
if day > timezone.localdate() or day < timezone.localdate() - timedelta(days=366):
|
||||
raise ValidationError("Выберите прошедший день или сегодня, в пределах года.")
|
||||
entry = DailyAllocation.objects.filter(employee=user, date=day).first()
|
||||
if data["version"] != (entry.version if entry else 0):
|
||||
raise ValidationError(
|
||||
"Запись изменена в другом окне. Обновите данные перед сохранением."
|
||||
)
|
||||
allocations = data["allocations"]
|
||||
total = sum((a["percent"] for a in allocations), Decimal(0))
|
||||
if total > 100 or (data["status"] == "confirmed" and total != 100):
|
||||
raise ValidationError(
|
||||
"Для подтверждения распределите ровно 100%. В черновике допустимо меньше."
|
||||
)
|
||||
allowed = set(available_projects(user, day).values_list("pk", flat=True))
|
||||
# Existing history remains editable after project completion, without adding new projects.
|
||||
if entry:
|
||||
allowed.update(
|
||||
entry.allocations.exclude(project=None).values_list("project_id", flat=True)
|
||||
)
|
||||
seen = set()
|
||||
for row in allocations:
|
||||
pid, activity = row.get("project"), row.get("activity", "")
|
||||
if bool(pid) == bool(activity):
|
||||
raise ValidationError("Выберите либо проект, либо вид общей деятельности.")
|
||||
if pid and pid not in allowed:
|
||||
raise ValidationError("Нет участия в выбранном проекте на эту дату.")
|
||||
key = (pid, activity)
|
||||
if key in seen:
|
||||
raise ValidationError(
|
||||
"Объедините повторяющиеся строки проекта или деятельности."
|
||||
)
|
||||
seen.add(key)
|
||||
record = attendance_for(user, day)
|
||||
if not entry:
|
||||
entry = DailyAllocation(employee=user, date=day)
|
||||
entry.status, entry.note = data["status"], data.get("note", "")
|
||||
entry.hours, entry.hours_source = (
|
||||
hours_for(record),
|
||||
"attendance" if record else "unknown",
|
||||
)
|
||||
entry.attendance_record = record
|
||||
entry.version += 1
|
||||
entry.save()
|
||||
entry.allocations.all().delete()
|
||||
TimeAllocation.objects.bulk_create(
|
||||
[
|
||||
TimeAllocation(
|
||||
day=entry,
|
||||
project_id=a.get("project"),
|
||||
activity=a.get("activity", ""),
|
||||
percent=a["percent"],
|
||||
)
|
||||
for a in allocations
|
||||
]
|
||||
)
|
||||
snapshot = {
|
||||
"version": entry.version,
|
||||
"status": entry.status,
|
||||
"hours": str(entry.hours) if entry.hours is not None else None,
|
||||
"hours_source": entry.hours_source,
|
||||
"note": entry.note,
|
||||
"allocations": [
|
||||
{
|
||||
"project": a.get("project"),
|
||||
"activity": a.get("activity", ""),
|
||||
"percent": str(a["percent"]),
|
||||
}
|
||||
for a in allocations
|
||||
],
|
||||
}
|
||||
AllocationRevision.objects.create(day=entry, actor=user, snapshot=snapshot)
|
||||
return day_data(user, day)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def claim_reminder(user):
|
||||
get_user_model().objects.select_for_update().get(pk=user.pk)
|
||||
now, today = timezone.now(), timezone.localdate()
|
||||
reminder, _ = AllocationReminder.objects.get_or_create(employee=user)
|
||||
if (
|
||||
not reminder.enabled
|
||||
or reminder.last_claim_date == today
|
||||
or (reminder.snoozed_until and reminder.snoozed_until > now)
|
||||
):
|
||||
return None
|
||||
confirmed = DailyAllocation.objects.filter(
|
||||
employee=user, status="confirmed"
|
||||
).values("date")
|
||||
# Completed past shifts only. Attendance, not a guessed Monday-Friday calendar,
|
||||
# determines working days including holidays, leave and overnight schedules.
|
||||
days = (
|
||||
AttendanceRecord.objects.filter(
|
||||
employee=user,
|
||||
analysis_run__status="success",
|
||||
work_hours__gt=0,
|
||||
date__lt=today,
|
||||
date__gte=today - timedelta(days=14),
|
||||
)
|
||||
.exclude(date__in=confirmed)
|
||||
.order_by("-date")
|
||||
)
|
||||
for record in days:
|
||||
if available_projects(user, record.date).exists():
|
||||
reminder.last_claim_date = today
|
||||
reminder.snoozed_until = None
|
||||
reminder.save()
|
||||
return record.date
|
||||
return None
|
||||
@ -186,6 +186,14 @@ def user_can_access_attendance_record(
|
||||
return bool(user.is_staff or user.is_superuser or record.employee_id == user.id)
|
||||
|
||||
|
||||
def user_can_access_project(user, project) -> bool:
|
||||
if not user or not user.is_authenticated or project is None:
|
||||
return False
|
||||
from projects.permissions import visible_projects
|
||||
|
||||
return visible_projects(user).filter(pk=project.pk).exists()
|
||||
|
||||
|
||||
def user_can_access_feed_post(user, post: Post | None) -> bool:
|
||||
return bool(user and user.is_authenticated and post is not None)
|
||||
|
||||
@ -209,6 +217,7 @@ def user_can_access_task_link_source(user, link) -> bool:
|
||||
TaskLinkedObjectKind.GUEST: user_can_access_guest,
|
||||
TaskLinkedObjectKind.GUEST_VISIT: user_can_access_guest_visit,
|
||||
TaskLinkedObjectKind.ATTENDANCE_RECORD: user_can_access_attendance_record,
|
||||
TaskLinkedObjectKind.PROJECT: user_can_access_project,
|
||||
}
|
||||
if link.kind == TaskLinkedObjectKind.MESSAGE:
|
||||
from communications.utils import user_can_access_chat
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 12:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0041_taskexecutioncycle'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='taskactivity',
|
||||
name='object_kind',
|
||||
field=models.CharField(blank=True, choices=[('project', 'Проект'), ('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка'), ('checklist_item', 'Пункт чек-листа'), ('comment', 'Комментарий')], max_length=32, verbose_name='Тип объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tasklinkedobject',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('project', 'Проект'), ('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'),
|
||||
),
|
||||
]
|
||||
@ -730,6 +730,7 @@ class TaskChecklistItem(models.Model):
|
||||
|
||||
|
||||
class TaskLinkedObjectKind(models.TextChoices):
|
||||
PROJECT = "project", "Проект"
|
||||
POST = "post", "Новость"
|
||||
MESSAGE = "message", "Сообщение"
|
||||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||||
@ -743,6 +744,7 @@ class TaskLinkedObjectKind(models.TextChoices):
|
||||
|
||||
|
||||
class TaskActivityObjectKind(models.TextChoices):
|
||||
PROJECT = "project", "Проект"
|
||||
POST = "post", "Новость"
|
||||
MESSAGE = "message", "Сообщение"
|
||||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||||
|
||||
@ -7,6 +7,7 @@ import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
|
||||
from commerce.models import (
|
||||
ExternalProject,
|
||||
@ -24,9 +25,17 @@ from commerce.services.moysklad_projects import (
|
||||
restore_sale_projects,
|
||||
)
|
||||
from commerce.services.ingestion import ingest_source_record
|
||||
from finance.analytics.projects import projects_report
|
||||
from finance.analytics.projects import local_project_scope, projects_report
|
||||
from finance.analytics.sales import sales_report
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
|
||||
from finance.models import EmployeePayRate, PayrollPeriod, PayrollRun, PayrollStatement
|
||||
from projects.models import (
|
||||
DailyAllocation,
|
||||
Project,
|
||||
ProjectExternalBinding,
|
||||
TimeAllocation,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
START, END = date(2026, 1, 1), date(2026, 1, 31)
|
||||
@ -132,9 +141,19 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
|
||||
occurred_at=paid.occurred_at,
|
||||
)
|
||||
user = user_factory()
|
||||
local = Project.objects.create(
|
||||
name="Локальный первый", manager=user, created_by=user, status="active"
|
||||
)
|
||||
ProjectExternalBinding.objects.create(
|
||||
project=local,
|
||||
source=source,
|
||||
external_id=project.external_id,
|
||||
external_name=project.name,
|
||||
)
|
||||
ReportingEntry.objects.create(
|
||||
kind="overhead",
|
||||
project=project,
|
||||
project=local,
|
||||
external_project=project,
|
||||
date=START,
|
||||
reference="overhead",
|
||||
amount=40,
|
||||
@ -142,12 +161,17 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
|
||||
)
|
||||
ReportingEntry.objects.create(
|
||||
kind="cash_out",
|
||||
project=project,
|
||||
project=local,
|
||||
external_project=project,
|
||||
date=START,
|
||||
reference="manual",
|
||||
amount=10,
|
||||
created_by=user,
|
||||
)
|
||||
day = DailyAllocation.objects.create(
|
||||
employee=user, date=START, status="confirmed", hours=Decimal("8")
|
||||
)
|
||||
TimeAllocation.objects.create(day=day, project=local, percent=Decimal("50"))
|
||||
sales = sales_report(START, END, "RUB")
|
||||
report = projects_report(START, END, "RUB", sales)
|
||||
assert report["summary"]["paid"] == 150
|
||||
@ -158,20 +182,220 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
|
||||
== 150
|
||||
)
|
||||
assert sum(r["paid"] for r in report["monthly"]) == 150
|
||||
scoped_sales = sales_report(START, END, "RUB", project=project)
|
||||
assert report["summary"]["labor_hours"] == 4
|
||||
assert report["summary"]["people_count"] == 1
|
||||
local_row = next(
|
||||
row for row in report["rows"] if row["id"] == f"project:{local.pk}"
|
||||
)
|
||||
assert local_row["name"] == "Локальный первый"
|
||||
assert local_row["external_project_count"] == 1
|
||||
scope = local_project_scope(project=local)
|
||||
scoped_sales = sales_report(START, END, "RUB", project_scope=scope)
|
||||
assert (
|
||||
scoped_sales["summary"]["profit"] == 250
|
||||
) # cash payments are not expensed twice
|
||||
scoped = projects_report(START, END, "RUB", scoped_sales, project=project)
|
||||
scoped = projects_report(
|
||||
START, END, "RUB", scoped_sales, project=local, scope=scope
|
||||
)
|
||||
assert scoped["summary"]["paid"] == 100
|
||||
assert scoped["summary"]["manual_paid"] == 10
|
||||
assert scoped["summary"]["overhead"] == 40
|
||||
assert (
|
||||
sales_report(START, END, "RUB", project="unassigned")["summary"]["expenses"]
|
||||
sales_report(
|
||||
START,
|
||||
END,
|
||||
"RUB",
|
||||
project_scope=local_project_scope(project="unassigned"),
|
||||
)["summary"]["expenses"]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_project_report_prices_confirmed_hours_from_approved_finance_rate(
|
||||
user_factory,
|
||||
):
|
||||
creator = user_factory(email="rate.creator@example.test")
|
||||
approver = user_factory(email="rate.approver@example.test")
|
||||
employee = user_factory(email="project.worker@example.test")
|
||||
project = Project.objects.create(
|
||||
name="Проект со стоимостью труда",
|
||||
manager=creator,
|
||||
created_by=creator,
|
||||
status="active",
|
||||
)
|
||||
PayrollPeriod.objects.create(
|
||||
code="project-labor-2026-01",
|
||||
name="Январь 2026",
|
||||
date_from=START,
|
||||
date_to=END,
|
||||
currency="RUB",
|
||||
created_by=creator,
|
||||
)
|
||||
EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount=Decimal("1600"),
|
||||
currency="RUB",
|
||||
effective_from=START,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
created_by=creator,
|
||||
approved_by=approver,
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
period_start=START,
|
||||
period_end=date(2026, 1, 2),
|
||||
status=AttendanceAnalysisRun.STATUS_SUCCESS,
|
||||
)
|
||||
for day in [START, date(2026, 1, 2)]:
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=employee,
|
||||
date=day,
|
||||
work_hours=8,
|
||||
expected_hours=8,
|
||||
effective_is_workday=True,
|
||||
)
|
||||
allocation_day = DailyAllocation.objects.create(
|
||||
employee=employee,
|
||||
date=START,
|
||||
status="confirmed",
|
||||
hours=Decimal("8"),
|
||||
)
|
||||
TimeAllocation.objects.create(
|
||||
day=allocation_day,
|
||||
project=project,
|
||||
percent=Decimal("50"),
|
||||
)
|
||||
|
||||
report = projects_report(
|
||||
START,
|
||||
END,
|
||||
"RUB",
|
||||
sales_report(START, END, "RUB"),
|
||||
)
|
||||
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
|
||||
assert row["labor_hours"] == Decimal("4")
|
||||
assert row["labor_cost"] == Decimal("400")
|
||||
assert row["preliminary_labor_cost"] == Decimal("400")
|
||||
assert row["exact_labor_cost"] == 0
|
||||
assert row["labor_cost_status"] == "preliminary"
|
||||
assert row["labor_hour_cost"] == Decimal("100")
|
||||
assert row["unpriced_labor_hours"] == 0
|
||||
assert row["labor_cost_complete"] is True
|
||||
assert row["project_result"] == Decimal("-400")
|
||||
assert report["summary"]["labor_cost"] == Decimal("400")
|
||||
|
||||
unpriced = projects_report(
|
||||
START,
|
||||
END,
|
||||
"USD",
|
||||
sales_report(START, END, "USD"),
|
||||
)
|
||||
unpriced_row = next(
|
||||
item for item in unpriced["rows"] if item["id"] == f"project:{project.pk}"
|
||||
)
|
||||
assert unpriced_row["labor_cost"] == 0
|
||||
assert unpriced_row["unpriced_labor_hours"] == Decimal("4")
|
||||
assert unpriced_row["project_result"] is None
|
||||
|
||||
|
||||
def test_project_report_uses_final_payroll_with_bonuses_only_after_period_close(
|
||||
user_factory,
|
||||
):
|
||||
creator = user_factory(email="closed.creator@example.test")
|
||||
approver = user_factory(email="closed.approver@example.test")
|
||||
employee = user_factory(email="closed.worker@example.test")
|
||||
project = Project.objects.create(
|
||||
name="Проект с закрытой зарплатой",
|
||||
manager=creator,
|
||||
created_by=creator,
|
||||
status="active",
|
||||
)
|
||||
period = PayrollPeriod.objects.create(
|
||||
code="project-labor-closed-2026-01",
|
||||
name="Закрытый январь 2026",
|
||||
date_from=START,
|
||||
date_to=END,
|
||||
currency="RUB",
|
||||
status=PayrollPeriodStatus.CLOSED,
|
||||
created_by=creator,
|
||||
)
|
||||
payroll_run = PayrollRun.objects.create(
|
||||
period=period,
|
||||
revision=1,
|
||||
status=PayrollRunStatus.PUBLISHED,
|
||||
requested_by=creator,
|
||||
approved_by=approver,
|
||||
approved_at=timezone.now(),
|
||||
published_by=approver,
|
||||
published_at=timezone.now(),
|
||||
gross_total=Decimal("2400"),
|
||||
payable_total=Decimal("2000"),
|
||||
)
|
||||
period.current_run = payroll_run
|
||||
period.save(update_fields=["current_run"])
|
||||
PayrollStatement.objects.create(
|
||||
run=payroll_run,
|
||||
employee=employee,
|
||||
currency="RUB",
|
||||
gross_before_adjustments=Decimal("1600"),
|
||||
adjustment_total=Decimal("800"),
|
||||
gross_total=Decimal("2400"),
|
||||
deduction_total=Decimal("400"),
|
||||
net_pay=Decimal("2000"),
|
||||
payment_total=0,
|
||||
payable=Decimal("2000"),
|
||||
)
|
||||
# This rate is the preliminary base. The closed period must instead use
|
||||
# the final 2,400 gross amount, including the 800 bonus/adjustment.
|
||||
EmployeePayRate.objects.create(
|
||||
employee=employee,
|
||||
amount=Decimal("1600"),
|
||||
currency="RUB",
|
||||
effective_from=START,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
created_by=creator,
|
||||
approved_by=approver,
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
attendance_run = AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
period_start=START,
|
||||
period_end=date(2026, 1, 2),
|
||||
status=AttendanceAnalysisRun.STATUS_SUCCESS,
|
||||
)
|
||||
for day in [START, date(2026, 1, 2)]:
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=attendance_run,
|
||||
employee=employee,
|
||||
date=day,
|
||||
work_hours=8,
|
||||
expected_hours=8,
|
||||
effective_is_workday=True,
|
||||
)
|
||||
allocation_day = DailyAllocation.objects.create(
|
||||
employee=employee,
|
||||
date=START,
|
||||
status="confirmed",
|
||||
hours=Decimal("8"),
|
||||
)
|
||||
TimeAllocation.objects.create(
|
||||
day=allocation_day,
|
||||
project=project,
|
||||
percent=Decimal("50"),
|
||||
)
|
||||
|
||||
report = projects_report(START, END, "RUB", sales_report(START, END, "RUB"))
|
||||
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
|
||||
assert row["labor_hours"] == Decimal("4")
|
||||
assert row["labor_cost"] == Decimal("600")
|
||||
assert row["exact_labor_cost"] == Decimal("600")
|
||||
assert row["preliminary_labor_cost"] == 0
|
||||
assert row["labor_cost_status"] == "exact"
|
||||
assert row["project_result"] == Decimal("-600")
|
||||
|
||||
|
||||
def test_projects_api_permissions_filter_and_journal(api_client, user_factory, source):
|
||||
p = payment(source)
|
||||
url = reverse("api:v1:finance-payroll:analytics-report")
|
||||
@ -179,23 +403,43 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
|
||||
assert api_client.get(payments_url).status_code == 401
|
||||
api_client.force_authenticate(user=user_factory())
|
||||
assert api_client.get(payments_url).status_code == 403
|
||||
api_client.force_authenticate(user=user_factory(staff=True))
|
||||
staff = user_factory(staff=True)
|
||||
local = Project.objects.create(
|
||||
name="Портальный проект", manager=staff, created_by=staff, status="active"
|
||||
)
|
||||
ProjectExternalBinding.objects.create(
|
||||
project=local,
|
||||
source=source,
|
||||
external_id=p.project.external_id,
|
||||
external_name=p.project.name,
|
||||
)
|
||||
api_client.force_authenticate(user=staff)
|
||||
params = {
|
||||
"date_from": str(START),
|
||||
"date_to": str(END),
|
||||
"project": p.project_id,
|
||||
"project": local.pk,
|
||||
"section": "projects",
|
||||
}
|
||||
response = api_client.get(url, params)
|
||||
assert response.status_code == 200
|
||||
assert response.data["projects"]["summary"]["paid"] == "100.0000"
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
options = api_client.get(reverse("api:v1:finance-payroll:analytics-options"))
|
||||
assert options.data["projects"] == [
|
||||
{
|
||||
"id": local.pk,
|
||||
"name": "Портальный проект",
|
||||
"source_ids": [source.pk],
|
||||
"archived": False,
|
||||
}
|
||||
]
|
||||
assert api_client.get(url, {**params, "project": "bad"}).status_code == 400
|
||||
other = SourceSystem.objects.create(code="other", name="Other")
|
||||
assert api_client.get(url, {**params, "source": other.pk}).status_code == 400
|
||||
response = api_client.get(payments_url, params)
|
||||
assert response.status_code == 200 and response.data["count"] == 1
|
||||
assert response.data["results"][0]["project_name"] == "Первый"
|
||||
assert response.data["results"][0]["project_name"] == "Портальный проект"
|
||||
assert response.data["results"][0]["external_project_name"] == "Первый"
|
||||
assert api_client.post(payments_url, {}).status_code == 405
|
||||
entry_url = reverse("api:v1:finance-payroll:analytics-entries")
|
||||
assert (
|
||||
@ -203,7 +447,7 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
|
||||
entry_url,
|
||||
{
|
||||
"kind": "overhead",
|
||||
"project": p.project_id,
|
||||
"project": local.pk,
|
||||
"date": "2026-01-01",
|
||||
"reference": "API",
|
||||
"amount": "5.00",
|
||||
@ -219,6 +463,56 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
|
||||
)
|
||||
|
||||
|
||||
def test_local_project_combines_bound_sources_and_keeps_unbound_separate(
|
||||
source, user_factory
|
||||
):
|
||||
manager = user_factory()
|
||||
local = Project.objects.create(
|
||||
name="Общий проект", manager=manager, created_by=manager, status="active"
|
||||
)
|
||||
first = payment(source, ref="first-bound").project
|
||||
second_source = SourceSystem.objects.create(code="second-source", name="Второй")
|
||||
second = payment(
|
||||
second_source,
|
||||
ref="second-bound",
|
||||
project={"id": "second-project", "name": "Внешний второй"},
|
||||
sum=20000,
|
||||
).project
|
||||
payment(
|
||||
second_source,
|
||||
ref="unbound-payment",
|
||||
project={"id": "unbound", "name": "Не связан"},
|
||||
sum=3000,
|
||||
)
|
||||
for external in [first, second]:
|
||||
ProjectExternalBinding.objects.create(
|
||||
project=local,
|
||||
source=external.source,
|
||||
external_id=external.external_id,
|
||||
external_name=external.name,
|
||||
)
|
||||
|
||||
sales = sales_report(START, END, "RUB")
|
||||
report = projects_report(START, END, "RUB", sales)
|
||||
local_row = next(row for row in report["rows"] if row["name"] == "Общий проект")
|
||||
assert local_row["paid"] == 300
|
||||
assert local_row["external_project_count"] == 2
|
||||
assert report["summary"]["unassigned_paid"] == 30
|
||||
|
||||
scope = local_project_scope(second_source, local)
|
||||
scoped_sales = sales_report(START, END, "RUB", second_source, project_scope=scope)
|
||||
scoped = projects_report(
|
||||
START,
|
||||
END,
|
||||
"RUB",
|
||||
scoped_sales,
|
||||
second_source,
|
||||
local,
|
||||
scope,
|
||||
)
|
||||
assert scoped["summary"]["paid"] == 200
|
||||
|
||||
|
||||
def test_saved_sale_project_backfill_is_idempotent(source):
|
||||
record = ingest_source_record(
|
||||
source=source,
|
||||
|
||||
558
backend/tests/projects/test_projects.py
Normal file
558
backend/tests/projects/test_projects.py
Normal file
@ -0,0 +1,558 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from attendance.models import AttendanceRecord, AttendanceAnalysisRun
|
||||
from commerce.models import SourceSystem, ExternalProject
|
||||
from finance.enums import ApprovalStatus
|
||||
from finance.models import EmployeePayRate, PayrollPeriod
|
||||
from projects.models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
ProjectExternalBinding,
|
||||
DailyAllocation,
|
||||
AllocationRevision,
|
||||
AllocationReminder,
|
||||
TimeAllocation,
|
||||
ProjectBoardLink,
|
||||
)
|
||||
from tasks.models import Task, TaskBoard, TaskColumn, TaskLinkedObject
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
ROOT = "/api/v1/projects/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_project(user_factory):
|
||||
manager, worker, outsider = user_factory(staff=True), user_factory(), user_factory()
|
||||
p = Project.objects.create(
|
||||
name="Локальная разработка",
|
||||
manager=manager,
|
||||
created_by=manager,
|
||||
status="active",
|
||||
)
|
||||
ProjectMembership.objects.create(project=p, employee=worker)
|
||||
return manager, worker, outsider, p, APIClient()
|
||||
|
||||
|
||||
def attendance(user, day, hours=8, status="success"):
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=user, period_start=day, period_end=day, status=status
|
||||
)
|
||||
return AttendanceRecord.objects.create(
|
||||
employee=user, date=day, analysis_run=run, work_hours=hours
|
||||
)
|
||||
|
||||
|
||||
def payload(p, day=None, **kwargs):
|
||||
return {
|
||||
"date": str(day or timezone.localdate()),
|
||||
"version": 0,
|
||||
"status": "confirmed",
|
||||
"allocations": [
|
||||
{"project": p.pk, "percent": "60"},
|
||||
{"activity": "department", "percent": "40"},
|
||||
],
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
def test_visibility_and_management(setup_project):
|
||||
manager, worker, outsider, p, c = setup_project
|
||||
c.force_authenticate(outsider)
|
||||
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
|
||||
assert c.get(ROOT).data["count"] == 0
|
||||
assert c.post(ROOT, {"name": "Нельзя", "manager": outsider.pk}).status_code == 403
|
||||
c.force_authenticate(worker)
|
||||
worker_detail = c.get(f"{ROOT}{p.pk}/")
|
||||
assert worker_detail.status_code == 200
|
||||
assert worker_detail.data["can_view_finances"] is False
|
||||
assert c.patch(f"{ROOT}{p.pk}/", {"name": "Нельзя"}).status_code == 403
|
||||
assert (
|
||||
c.post(f"{ROOT}{p.pk}/members/", {"employee": outsider.pk}).status_code == 403
|
||||
)
|
||||
assert c.get(f"{ROOT}{p.pk}/bindings/").status_code == 403
|
||||
assert c.get(f"{ROOT}{p.pk}/finances/").status_code == 403
|
||||
c.force_authenticate(manager)
|
||||
assert c.get(f"{ROOT}{p.pk}/").data["can_view_finances"] is True
|
||||
assert c.delete(f"{ROOT}{p.pk}/").status_code == 400
|
||||
assert c.patch(f"{ROOT}{p.pk}/", {"status": "archived"}).status_code == 200
|
||||
assert p.events.filter(action="Карточка изменена").exists()
|
||||
|
||||
|
||||
def test_project_links_real_tasks_and_boards_with_access_checks(setup_project):
|
||||
manager, worker, outsider, project, client = setup_project
|
||||
board = TaskBoard.objects.create(name="Рабочая доска", created_by=manager)
|
||||
column = TaskColumn.objects.create(board=board, name="В работе")
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Связанная задача",
|
||||
created_by=manager,
|
||||
)
|
||||
client.force_authenticate(manager)
|
||||
|
||||
task_url = f"{ROOT}{project.pk}/task-links/"
|
||||
task_response = client.post(task_url, {"task_id": task.pk}, format="json")
|
||||
assert task_response.status_code == 200
|
||||
assert task_response.data[0]["id"] == task.pk
|
||||
assert task_response.data[0]["board_id"] == board.pk
|
||||
assert TaskLinkedObject.objects.filter(task=task, object_id=project.pk).exists()
|
||||
|
||||
board_url = f"{ROOT}{project.pk}/board-links/"
|
||||
board_response = client.post(board_url, {"board_id": board.pk}, format="json")
|
||||
assert board_response.status_code == 200
|
||||
assert board_response.data == [
|
||||
{
|
||||
"id": ProjectBoardLink.objects.get().pk,
|
||||
"board_id": board.pk,
|
||||
"name": "Рабочая доска",
|
||||
"url": f"/tasks?board={board.pk}",
|
||||
}
|
||||
]
|
||||
|
||||
client.force_authenticate(worker)
|
||||
assert client.get(task_url).status_code == 200
|
||||
assert client.get(board_url).status_code == 200
|
||||
assert client.post(task_url, {"task_id": task.pk}).status_code == 403
|
||||
|
||||
client.force_authenticate(outsider)
|
||||
assert client.get(task_url).status_code == 404
|
||||
assert client.get(board_url).status_code == 404
|
||||
|
||||
client.force_authenticate(manager)
|
||||
task_link_id = TaskLinkedObject.objects.get(task=task, object_id=project.pk).pk
|
||||
assert client.delete(task_url, {"id": task_link_id}, format="json").data == []
|
||||
board_link_id = ProjectBoardLink.objects.get().pk
|
||||
assert client.delete(board_url, {"id": board_link_id}, format="json").data == []
|
||||
|
||||
|
||||
def test_creation_and_dates(setup_project):
|
||||
manager, _, _, _, c = setup_project
|
||||
c.force_authenticate(manager)
|
||||
assert (
|
||||
c.post(
|
||||
ROOT,
|
||||
{
|
||||
"name": "Новый",
|
||||
"manager": manager.pk,
|
||||
"starts_on": "2026-02-01",
|
||||
"ends_on": "2026-01-01",
|
||||
},
|
||||
format="json",
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
r = c.post(ROOT, {"name": "Новый", "manager": manager.pk}, format="json")
|
||||
assert r.status_code == 201, r.data
|
||||
p = Project.objects.get(pk=r.data["id"])
|
||||
assert p.memberships.get().employee == manager
|
||||
assert not p.bindings.exists()
|
||||
|
||||
|
||||
def test_snapshots_hours_and_conflicts(setup_project):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
record = attendance(worker, timezone.localdate())
|
||||
r = c.post(ROOT + "my-day/", payload(p), format="json")
|
||||
assert r.status_code == 200, r.data
|
||||
assert r.data["hours"] == Decimal("8.000") and r.data["version"] == 1
|
||||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||||
record.work_hours = 6
|
||||
record.save()
|
||||
assert c.get(ROOT + "my-day/").data["attendance_changed"] is True
|
||||
r = c.post(ROOT + "my-day/", payload(p, version=1), format="json")
|
||||
assert r.status_code == 200 and r.data["hours"] == Decimal("6.000")
|
||||
assert AllocationRevision.objects.count() == 2
|
||||
assert (
|
||||
AllocationRevision.objects.order_by("id").first().snapshot["hours"] == "8.000"
|
||||
)
|
||||
assert DailyAllocation.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allocations",
|
||||
[
|
||||
[{"percent": "100"}],
|
||||
[{"activity": "training", "percent": "101"}],
|
||||
[{"activity": "training", "percent": "99"}],
|
||||
[
|
||||
{"activity": "training", "percent": "50"},
|
||||
{"activity": "training", "percent": "50"},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_invalid_distribution_is_atomic(setup_project, allocations):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
assert (
|
||||
c.post(
|
||||
ROOT + "my-day/", payload(p, allocations=allocations), format="json"
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert not DailyAllocation.objects.exists()
|
||||
|
||||
|
||||
def test_membership_and_future_dates(setup_project):
|
||||
_, worker, outsider, p, c = setup_project
|
||||
c.force_authenticate(outsider)
|
||||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||||
c.force_authenticate(worker)
|
||||
assert (
|
||||
c.post(
|
||||
ROOT + "my-day/",
|
||||
payload(p, day=timezone.localdate() + timedelta(days=1)),
|
||||
format="json",
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
p.memberships.update(starts_on=timezone.localdate() + timedelta(days=1))
|
||||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
|
||||
|
||||
|
||||
def test_unknown_hours_drafts_and_private_reports(setup_project):
|
||||
manager, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
r = c.post(ROOT + "my-day/", payload(p), format="json")
|
||||
assert r.status_code == 200 and r.data["hours"] is None
|
||||
report = c.get(f"{ROOT}{p.pk}/time-report/").data
|
||||
assert (
|
||||
report["unknown_days"] == 1
|
||||
and report["confirmed_hours"] == 0
|
||||
and report["scope"] == "self"
|
||||
)
|
||||
c.force_authenticate(manager)
|
||||
attendance(manager, timezone.localdate(), 10)
|
||||
r = c.post(
|
||||
ROOT + "my-day/",
|
||||
payload(p, status="draft", allocations=[{"project": p.pk, "percent": "30"}]),
|
||||
format="json",
|
||||
)
|
||||
assert r.status_code == 200
|
||||
report = c.get(f"{ROOT}{p.pk}/time-report/").data
|
||||
assert report["draft_days"] == 1 and report["confirmed_hours"] == 0
|
||||
c.force_authenticate(worker)
|
||||
assert len(c.get(f"{ROOT}{p.pk}/time-report/").data["rows"]) == 1
|
||||
|
||||
|
||||
def test_reminders_and_snooze(setup_project):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
yesterday = timezone.localdate() - timedelta(days=1)
|
||||
attendance(worker, yesterday)
|
||||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] == yesterday
|
||||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||||
assert (
|
||||
c.post(ROOT + "reminder/", {"snooze": True}, format="json").status_code == 200
|
||||
)
|
||||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||||
AllocationReminder.objects.update(
|
||||
snoozed_until=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
assert (
|
||||
c.post(ROOT + "my-day/", payload(p, day=yesterday), format="json").status_code
|
||||
== 200
|
||||
)
|
||||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||||
|
||||
|
||||
def test_failed_attendance_excluded(setup_project):
|
||||
_, worker, _, _, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
yesterday = timezone.localdate() - timedelta(days=1)
|
||||
attendance(worker, yesterday, status="failed")
|
||||
assert c.get(ROOT + "my-day/", {"date": str(yesterday)}).data["hours"] is None
|
||||
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
|
||||
|
||||
|
||||
def test_binding_identity(setup_project):
|
||||
manager, _, _, p, c = setup_project
|
||||
c.force_authenticate(manager)
|
||||
a = SourceSystem.objects.create(code="test-project-a", name="A")
|
||||
b = SourceSystem.objects.create(code="test-project-b", name="B")
|
||||
data = {"source": a.pk, "external_id": "same", "external_name": "Внешний"}
|
||||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
|
||||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 400
|
||||
data["source"] = b.pk
|
||||
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
|
||||
assert (
|
||||
ProjectExternalBinding.objects.count() == 2
|
||||
and ExternalProject.objects.count() == 0
|
||||
)
|
||||
|
||||
|
||||
def test_resource_urls(setup_project):
|
||||
manager, _, _, p, c = setup_project
|
||||
c.force_authenticate(manager)
|
||||
for url in [
|
||||
"https://evil.test/tasks",
|
||||
"//evil.test/tasks",
|
||||
"/profile",
|
||||
"/tasks\\evil",
|
||||
]:
|
||||
assert (
|
||||
c.post(
|
||||
f"{ROOT}{p.pk}/resources/",
|
||||
{"title": "X", "kind": "task", "url": url},
|
||||
format="json",
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
c.post(
|
||||
f"{ROOT}{p.pk}/resources/",
|
||||
{"title": "Задача", "kind": "task", "url": "/tasks?task=12"},
|
||||
format="json",
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_finances_use_source_scoped_bindings_and_valid_payments(setup_project):
|
||||
from commerce.services.moysklad_projects import normalize_project_payment
|
||||
|
||||
manager, approver, _, p, c = setup_project
|
||||
c.force_authenticate(manager)
|
||||
source = SourceSystem.objects.create(code="finance-project-a", name="A")
|
||||
other = SourceSystem.objects.create(code="finance-project-b", name="B")
|
||||
|
||||
def pay(src, ref, project_id, amount, applicable=True):
|
||||
return normalize_project_payment(
|
||||
src,
|
||||
{
|
||||
"id": ref,
|
||||
"moment": f"{timezone.localdate()} 12:00:00",
|
||||
"sum": amount,
|
||||
"name": ref,
|
||||
"applicable": applicable,
|
||||
"project": {"id": project_id, "name": project_id},
|
||||
},
|
||||
"paymentout",
|
||||
timezone.now(),
|
||||
)
|
||||
|
||||
pay(source, "a", "first", 10000)
|
||||
pay(source, "b", "second", 20000)
|
||||
pay(other, "c", "first", 90000)
|
||||
pay(source, "cancelled", "first", 50000, False)
|
||||
stale = pay(source, "stale", "first", 60000)
|
||||
stale.missing_from_source = True
|
||||
stale.save()
|
||||
for ref in ["first", "second"]:
|
||||
ProjectExternalBinding.objects.create(project=p, source=source, external_id=ref)
|
||||
today = timezone.localdate()
|
||||
PayrollPeriod.objects.create(
|
||||
code="project-finance-current",
|
||||
date_from=today,
|
||||
date_to=today,
|
||||
currency="RUB",
|
||||
created_by=manager,
|
||||
)
|
||||
EmployeePayRate.objects.create(
|
||||
employee=manager,
|
||||
amount=Decimal("800"),
|
||||
effective_from=today,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
created_by=manager,
|
||||
approved_by=approver,
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
attendance_record = attendance(manager, today)
|
||||
attendance_record.expected_hours = 8
|
||||
attendance_record.save(update_fields=["expected_hours"])
|
||||
allocation_day = DailyAllocation.objects.create(
|
||||
employee=manager,
|
||||
date=today,
|
||||
status="confirmed",
|
||||
hours=Decimal("8"),
|
||||
attendance_record=attendance_record,
|
||||
)
|
||||
TimeAllocation.objects.create(
|
||||
day=allocation_day,
|
||||
project=p,
|
||||
percent=Decimal("50"),
|
||||
)
|
||||
r = c.get(f"{ROOT}{p.pk}/finances/")
|
||||
assert r.status_code == 200, r.data
|
||||
assert r.data["paid"] == Decimal("300")
|
||||
assert r.data["payment_count"] == 2
|
||||
assert r.data["external_project_count"] == 2
|
||||
assert r.data["labor_hours"] == Decimal("4")
|
||||
assert r.data["labor_cost"] == Decimal("400")
|
||||
assert r.data["preliminary_labor_cost"] == Decimal("400")
|
||||
assert r.data["exact_labor_cost"] == 0
|
||||
assert r.data["labor_cost_status"] == "preliminary"
|
||||
assert r.data["labor_hour_cost"] == Decimal("100")
|
||||
assert r.data["labor_cost_complete"] is True
|
||||
assert r.data["labor"][0]["employee_id"] == manager.pk
|
||||
|
||||
|
||||
def test_confirmed_time_is_weighted_by_daily_hours(setup_project):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
for offset, hours in [(0, 8), (1, 4)]:
|
||||
day = timezone.localdate() - timedelta(days=offset)
|
||||
attendance(worker, day, hours)
|
||||
assert (
|
||||
c.post(ROOT + "my-day/", payload(p, day=day), format="json").status_code
|
||||
== 200
|
||||
)
|
||||
r = c.get(
|
||||
f"{ROOT}{p.pk}/time-report/",
|
||||
{
|
||||
"from": str(timezone.localdate() - timedelta(days=1)),
|
||||
"to": str(timezone.localdate()),
|
||||
},
|
||||
)
|
||||
assert r.data["confirmed_hours"] == Decimal("7.2")
|
||||
|
||||
|
||||
def test_archived_project_preserves_existing_day_history(setup_project):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 200
|
||||
p.status = "archived"
|
||||
p.save()
|
||||
assert c.get(ROOT + "my-day/").data["projects"] == []
|
||||
assert (
|
||||
c.post(ROOT + "my-day/", payload(p, version=1), format="json").status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
c.post(
|
||||
ROOT + "my-day/",
|
||||
payload(p, day=timezone.localdate() - timedelta(days=1)),
|
||||
format="json",
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
|
||||
|
||||
def test_mine_filter_and_malformed_requests(setup_project):
|
||||
manager, _, outsider, p, c = setup_project
|
||||
Project.objects.create(name="Другой", manager=outsider, created_by=outsider)
|
||||
c.force_authenticate(manager)
|
||||
assert c.get(ROOT).data["count"] == 2
|
||||
assert c.get(ROOT, {"mine": "true"}).data["count"] == 1
|
||||
assert (
|
||||
c.post(
|
||||
f"{ROOT}{p.pk}/members/", {"employee": "invalid"}, format="json"
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
c.delete(f"{ROOT}{p.pk}/stages/", {"id": "invalid"}, format="json").status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
c.get(
|
||||
f"{ROOT}{p.pk}/finances/", {"from": "9999-12-30", "to": "9999-12-31"}
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert c.get(f"{ROOT}{p.pk}/finances/", {"currency": "РУБ"}).status_code == 400
|
||||
|
||||
|
||||
def test_external_candidates_only_return_active_unbound_records(setup_project):
|
||||
manager, _, _, project, client = setup_project
|
||||
client.force_authenticate(manager)
|
||||
source = SourceSystem.objects.create(code="candidate-source", name="МойСклад")
|
||||
available = ExternalProject.objects.create(
|
||||
source=source,
|
||||
external_id="available",
|
||||
name="Рабочий проект",
|
||||
code="PROJECT-7",
|
||||
description="Проект из внешней системы",
|
||||
)
|
||||
bound = ExternalProject.objects.create(
|
||||
source=source, external_id="bound", name="Уже связан"
|
||||
)
|
||||
ExternalProject.objects.create(
|
||||
source=source, external_id="archived", name="Архив", archived=True
|
||||
)
|
||||
ProjectExternalBinding.objects.create(
|
||||
project=project,
|
||||
source=source,
|
||||
external_id=bound.external_id,
|
||||
external_name=bound.name,
|
||||
)
|
||||
|
||||
result = client.get(f"{ROOT}external-candidates/")
|
||||
assert result.status_code == 200
|
||||
assert result.data["count"] == 1
|
||||
assert result.data["results"][0]["external_id"] == available.external_id
|
||||
assert result["Cache-Control"] == "private, no-store"
|
||||
detail = client.get(
|
||||
f"{ROOT}external-project/",
|
||||
{"source": source.pk, "external_id": available.external_id},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
assert detail.data["external_name"] == available.name
|
||||
assert detail.data["code"] == "PROJECT-7"
|
||||
assert detail.data["description"] == "Проект из внешней системы"
|
||||
assert detail.data["payment_count"] == 0
|
||||
assert detail.data["payments"] == []
|
||||
assert detail.data["sale_count"] == 0
|
||||
assert detail.data["bound"] is False
|
||||
assert (
|
||||
client.get(
|
||||
f"{ROOT}external-project/",
|
||||
{"source": source.pk, "external_id": "missing"},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.get(f"{ROOT}external-candidates/", {"q": "не найдено"}).data["count"]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_create_from_external_is_atomic_and_preserves_source(setup_project):
|
||||
manager, _, outsider, _, client = setup_project
|
||||
source = SourceSystem.objects.create(code="import-source", name="МойСклад")
|
||||
external = ExternalProject.objects.create(
|
||||
source=source, external_id="external-project", name="Разработка автомата"
|
||||
)
|
||||
data = {
|
||||
"source": source.pk,
|
||||
"external_id": external.external_id,
|
||||
"name": "Локальная разработка автомата",
|
||||
"description": "Внутренняя карточка",
|
||||
"customer": "Заказчик",
|
||||
"manager": manager.pk,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
client.force_authenticate(outsider)
|
||||
assert client.post(f"{ROOT}from-external/", data, format="json").status_code == 403
|
||||
client.force_authenticate(manager)
|
||||
result = client.post(f"{ROOT}from-external/", data, format="json")
|
||||
assert result.status_code == 201, result.data
|
||||
local = Project.objects.get(pk=result.data["id"])
|
||||
assert local.name == data["name"]
|
||||
assert local.bindings.get().external_id == external.external_id
|
||||
assert local.memberships.get().employee == manager
|
||||
assert local.events.get().action == "Проект создан из внешнего проекта"
|
||||
external.refresh_from_db()
|
||||
assert external.name == "Разработка автомата"
|
||||
|
||||
project_count = Project.objects.count()
|
||||
duplicate = client.post(f"{ROOT}from-external/", data, format="json")
|
||||
assert duplicate.status_code == 400
|
||||
assert Project.objects.count() == project_count
|
||||
|
||||
|
||||
def test_project_options_only_offer_manageable_projects(setup_project):
|
||||
manager, worker, outsider, project, client = setup_project
|
||||
other = Project.objects.create(name="Чужой", manager=outsider, created_by=outsider)
|
||||
client.force_authenticate(worker)
|
||||
options = client.get(f"{ROOT}options/").data
|
||||
assert options["manageable_projects"] == []
|
||||
client.force_authenticate(manager)
|
||||
options = client.get(f"{ROOT}options/").data
|
||||
assert {row["id"] for row in options["manageable_projects"]} == {
|
||||
project.pk,
|
||||
other.pk,
|
||||
}
|
||||
@ -37,3 +37,5 @@ Troubleshooting должен находиться в соответствующ
|
||||
session report.
|
||||
|
||||
При добавлении документа сначала проверьте, нельзя ли обновить существующий guide или README модуля. Отдельные `*_PLAN.md`, `*_PROGRESS.md`, `*_COMPLETE.md` и session reports в репозитории не создаются.
|
||||
|
||||
Локальные проекты, внешние привязки и ежедневное участие: [Projects](../backend/projects/README.md).
|
||||
|
||||
@ -50,6 +50,9 @@
|
||||
в производственном отчёте и рассчитывается тем же сервисом.
|
||||
- Журнал подтверждённых фактов: выпуск, производственные закупки, уплаченные налоги
|
||||
сотрудника, общие расходы, поступления и выплаты денег.
|
||||
- Локальные проекты объединяют продажи, расходы и платежи связанных внешних
|
||||
объектов; подтверждённые часы получают предварительную оценку до закрытия
|
||||
зарплатного периода и точную оценку по его итоговому начислению после закрытия.
|
||||
- Выбор дат включительно, валюты и источника продаж. Поиск, страницы таблиц и CSV
|
||||
всех строк текущей группировки с учётом поиска. Диапазон ограничен 367 днями.
|
||||
|
||||
@ -110,7 +113,9 @@ API дополнительно возвращает арифметический
|
||||
относиться к конкретному конечному товару либо оставаться общей. Производственные
|
||||
закупки не вычитаются повторно из прибыли поверх себестоимости продаж.
|
||||
|
||||
Фильтр источника влияет на продажи и связанные расходы; журнал остаётся общим.
|
||||
Фильтр проекта использует локальную карточку и применяется к продажам через её
|
||||
внешние привязки, а к ручному журналу и трудозатратам — напрямую. Фильтр источника
|
||||
влияет на продажи и связанные расходы; журнал остаётся общим.
|
||||
Денежный поток считается только по поступлениям и выплатам из журнала. Нулевое
|
||||
число записей не подтверждает отсутствие фактических выплат, налогов или выпуска.
|
||||
|
||||
@ -132,20 +137,22 @@ no-store`; фронтовое скрытие ссылки не подменяе
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/finance/analytics/report/?section=sales|people|production`
|
||||
- `GET /api/v1/finance/analytics/report/?section=sales|people|production|projects`
|
||||
- `GET /api/v1/finance/analytics/options/?search=...`
|
||||
- `GET|POST /api/v1/finance/analytics/entries/`
|
||||
- `POST /api/v1/finance/analytics/entries/{id}/void/`
|
||||
|
||||
Параметры отчёта и списка фактов: `date_from`, `date_to`, `currency`; параметр
|
||||
`source` применяется к продажам. Список фактов использует стандартную пагинацию API.
|
||||
`options` возвращает первые 100 товаров и сотрудников по строке поиска.
|
||||
Параметры отчёта и списка фактов: `date_from`, `date_to`, `currency`, локальный
|
||||
`project` либо `unassigned`; параметр `source` применяется к продажам и внешним
|
||||
привязкам. Список фактов использует стандартную пагинацию API. `options`
|
||||
возвращает локальные проекты и первые 100 товаров и сотрудников по строке поиска.
|
||||
CSV экранирует кавычки, переносы строк и потенциальные формулы в текстовых полях.
|
||||
|
||||
## Подключение и проверка
|
||||
|
||||
Перед запуском обновлённого backend выполните `python manage.py migrate`:
|
||||
миграции Commerce 0012–0013 и Finance 0032 добавляют клиента продажи и журнал фактов.
|
||||
миграции Commerce 0012–0014, Projects 0001 и Finance 0032–0034 добавляют клиента
|
||||
продажи, внешний справочник, локальные проекты и журнал фактов.
|
||||
Для старых документов без имени клиента может потребоваться повторная синхронизация.
|
||||
|
||||
```bash
|
||||
@ -169,13 +176,27 @@ npx tsc --noEmit
|
||||
Индекс здоровья, планы и прогнозы из примерных изображений не включены в объём
|
||||
реализации текстового ТЗ.
|
||||
|
||||
## Проекты МойСклад
|
||||
## Локальные проекты и внешние источники
|
||||
|
||||
Раздел «Проекты» показывает оплаченные расходы по проектам и статьям, документы
|
||||
платежей, ручные выплаты, закупки и общие расходы из журнала, а также продажи проекта.
|
||||
Фильтр проекта работает в продажах, производстве, проектах и журнале. Кадровые
|
||||
показатели не распределяются по проектам. Вариант «Без проекта» позволяет найти
|
||||
документы, для которых привязка не заполнена.
|
||||
Раздел «Проекты» использует локальную карточку портала как основную строку. В неё
|
||||
по точным внешним привязкам собираются оплаченные расходы, документы платежей,
|
||||
продажи и прямые расходы всех подключённых сервисов. Ручные выплаты, закупки и
|
||||
общие расходы журнала ссылаются на локальную карточку напрямую. Подтверждённые
|
||||
распределения сотрудников добавляют трудозатраты в часах, число дней учёта и
|
||||
участников. Пока расчётный период открыт, стоимость часа предварительная: система
|
||||
использует текущий расчёт зарплаты, а до его появления — действующую утверждённую
|
||||
базовую ставку, разделённую на норму часов периода. После закрытия периода
|
||||
стоимость становится точной: итоговое начисление из опубликованного расчётного
|
||||
листка, включая премии, доплаты и корректировки, распределяется по фактически
|
||||
отработанным часам. Полученная стоимость часа умножается только на подтверждённые
|
||||
часы проекта. Черновые ставки не используются. Закрытый период без итогового
|
||||
листка, а также часы без периода, нормы или фактических часов сохраняются как
|
||||
неоценённые и не превращаются в ноль.
|
||||
|
||||
Фильтр локального проекта работает в продажах, производстве, проектах и журнале.
|
||||
Вариант «Без привязки» объединяет внешние документы, для которых ещё не выбрана
|
||||
локальная карточка, а также документы без проекта источника. В списке платежей
|
||||
сохраняются и локальное имя, и исходное имя проекта для проверки происхождения.
|
||||
|
||||
Синхронизация получает `/entity/project` (включая архив), `/entity/paymentout`
|
||||
и `/entity/cashout` с проектом, статьёй расходов, получателем и валютой. Продажи
|
||||
@ -183,7 +204,7 @@ npx tsc --noEmit
|
||||
восстанавливается из сохранённого исходного документа, если поле присутствует.
|
||||
Проекты идентифицируются сочетанием источника и внешнего ID, а не названием.
|
||||
|
||||
«Оплачено в МойСклад» — сумма проведённых исходящих платежей и расходных ордеров
|
||||
«Оплачено по источникам» — сумма проведённых исходящих платежей и расходных ордеров
|
||||
за период в выбранной валюте. Проект определяется по явному полю документа;
|
||||
из связанных заказов и приёмок он не угадывается. Непроведённые документы и
|
||||
документы, исчезнувшие после полностью успешного обхода источника, исключаются.
|
||||
@ -204,7 +225,9 @@ npx tsc --noEmit
|
||||
доступно в ручной записи, продаже и прямом расходе. Справочник проектов возвращается
|
||||
в `analytics/options/`. Права и `no-store` совпадают с остальной аналитикой.
|
||||
|
||||
Миграции: Commerce 0014 и Finance 0033. Обычная синхронизация теперь включает
|
||||
Миграции: Commerce 0014, Projects 0001 и Finance 0033–0034. Миграция 0034
|
||||
сохраняет прежнюю внешнюю связь ручной записи для аудита и заполняет локальный
|
||||
проект там, где уже существовала точная привязка. Обычная синхронизация включает
|
||||
проекты и исходящие платежи. Для отдельной загрузки без товаров и изображений:
|
||||
|
||||
```bash
|
||||
|
||||
@ -6,6 +6,7 @@ import { KeyRound, Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
|
||||
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
|
||||
import { ProjectParticipationProfileSection } from "@/components/projects/ProjectParticipationProfileSection";
|
||||
import EmployeePayrollWorkProfileSection from "@/components/users/EmployeePayrollWorkProfileSection";
|
||||
import EmployeeRequestsProfileSections from "@/components/users/EmployeeRequestsProfileSections";
|
||||
import {
|
||||
@ -198,6 +199,7 @@ export default function ProfilePage() {
|
||||
|
||||
<EmployeeRequestsProfileSections employeeId={user.id} />
|
||||
|
||||
<ProjectParticipationProfileSection />
|
||||
<EmployeePayrollWorkProfileSection />
|
||||
|
||||
<ProfileWorkScheduleCard
|
||||
|
||||
1376
frontend/src/app/projects/[projectId]/page.tsx
Normal file
1376
frontend/src/app/projects/[projectId]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
904
frontend/src/app/projects/page.tsx
Normal file
904
frontend/src/app/projects/page.tsx
Normal file
@ -0,0 +1,904 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FolderKanban,
|
||||
Plus,
|
||||
Clock3,
|
||||
Search,
|
||||
HelpCircle,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
PlugZap,
|
||||
Link2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CalendarDays,
|
||||
UserRound,
|
||||
Building2,
|
||||
Eye,
|
||||
ReceiptText,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
|
||||
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type {
|
||||
ExternalProjectDetail,
|
||||
ExternalProjectOption,
|
||||
LocalProject,
|
||||
ProjectOption,
|
||||
} from "@/types/projects";
|
||||
import { ProjectEditor } from "@/components/projects/ProjectEditor";
|
||||
import { DailyTimeAllocationModal } from "@/components/projects/DailyTimeAllocationModal";
|
||||
import {
|
||||
Panel,
|
||||
buttonClass,
|
||||
primaryClass,
|
||||
fieldClass,
|
||||
projectStatuses,
|
||||
errorText,
|
||||
today,
|
||||
} from "@/components/projects/shared";
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<LocalProject[]>([]),
|
||||
[canCreate, setCanCreate] = useState(false),
|
||||
[create, setCreate] = useState(false),
|
||||
[date, setDate] = useState<string | null>(null),
|
||||
[help, setHelp] = useState(false),
|
||||
[search, setSearch] = useState(""),
|
||||
[page, setPage] = useState(1),
|
||||
[count, setCount] = useState(0),
|
||||
[next, setNext] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[loading, setLoading] = useState(true),
|
||||
[wide, setWide] = useState(false),
|
||||
[calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [externalProjects, setExternalProjects] = useState<
|
||||
ExternalProjectOption[]
|
||||
>([]),
|
||||
[externalSearch, setExternalSearch] = useState(""),
|
||||
[externalPage, setExternalPage] = useState(1),
|
||||
[externalCount, setExternalCount] = useState(0),
|
||||
[externalNext, setExternalNext] = useState(false),
|
||||
[externalLoading, setExternalLoading] = useState(true),
|
||||
[externalOpen, setExternalOpen] = useState(false),
|
||||
[importExternal, setImportExternal] =
|
||||
useState<ExternalProjectOption | null>(null),
|
||||
[linkExternal, setLinkExternal] = useState<ExternalProjectOption | null>(
|
||||
null,
|
||||
),
|
||||
[linkProjectId, setLinkProjectId] = useState(0),
|
||||
[externalBusy, setExternalBusy] = useState(false),
|
||||
[manageableProjects, setManageableProjects] = useState<ProjectOption[]>([]);
|
||||
const [previewExternal, setPreviewExternal] =
|
||||
useState<ExternalProjectOption | null>(null),
|
||||
[externalDetail, setExternalDetail] =
|
||||
useState<ExternalProjectDetail | null>(null),
|
||||
[externalDetailLoading, setExternalDetailLoading] = useState(false),
|
||||
[externalDetailError, setExternalDetailError] = useState("");
|
||||
const [calendar, setCalendar] = useState<
|
||||
{ date: string; status: string; hours: string | null }[]
|
||||
>([]);
|
||||
const refreshCalendar = () =>
|
||||
apiClient
|
||||
.getProjectCalendar()
|
||||
.then(setCalendar)
|
||||
.catch((e) => setError(errorText(e)));
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
Promise.all([apiClient.getProjectOptions(), apiClient.getProjectCalendar()])
|
||||
.then(([o, c]) => {
|
||||
if (live) {
|
||||
setCanCreate(o.can_create);
|
||||
setCalendar(c);
|
||||
setManageableProjects(o.manageable_projects);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(true);
|
||||
apiClient
|
||||
.getLocalProjects(search, page)
|
||||
.then((r) => {
|
||||
if (live) {
|
||||
setProjects(r.results);
|
||||
setCount(r.count);
|
||||
setNext(Boolean(r.next));
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoading(false);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [search, page]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const timer = setTimeout(() => {
|
||||
setExternalLoading(true);
|
||||
apiClient
|
||||
.getExternalProjectCandidates(externalSearch, externalPage)
|
||||
.then((result) => {
|
||||
if (!live) return;
|
||||
setExternalProjects(result.results);
|
||||
setExternalCount(result.count);
|
||||
setExternalNext(Boolean(result.next));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setExternalLoading(false);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [externalSearch, externalPage]);
|
||||
const removeExternalCandidate = (external: ExternalProjectOption) => {
|
||||
setExternalProjects((current) =>
|
||||
current.filter(
|
||||
(item) =>
|
||||
item.source !== external.source ||
|
||||
item.external_id !== external.external_id,
|
||||
),
|
||||
);
|
||||
setExternalCount((current) => Math.max(0, current - 1));
|
||||
};
|
||||
const openExternalPreview = async (external: ExternalProjectOption) => {
|
||||
setPreviewExternal(external);
|
||||
setExternalDetail(null);
|
||||
setExternalDetailError("");
|
||||
setExternalDetailLoading(true);
|
||||
try {
|
||||
setExternalDetail(
|
||||
await apiClient.getExternalProjectDetail(
|
||||
external.source,
|
||||
external.external_id,
|
||||
),
|
||||
);
|
||||
} catch (detailError) {
|
||||
setExternalDetailError(errorText(detailError));
|
||||
} finally {
|
||||
setExternalDetailLoading(false);
|
||||
}
|
||||
};
|
||||
const formatDate = (value: string | null) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(`${value}T00:00:00`))
|
||||
: null;
|
||||
const formatDateTime = (value: string | null) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value))
|
||||
: "Нет данных";
|
||||
const formatAmount = (value: string | number, currency: string) =>
|
||||
`${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(Number(value))} ${currency}`;
|
||||
const projectStatusClass: Record<string, string> = {
|
||||
planned: "app-badge",
|
||||
active: "app-feedback-success",
|
||||
paused: "app-feedback-warning",
|
||||
completed: "app-badge-accent",
|
||||
archived: "app-badge",
|
||||
};
|
||||
return (
|
||||
<AppShell desktopWideMode={wide} onDesktopWideModeChange={setWide}>
|
||||
<div
|
||||
className={`mx-auto w-full space-y-5 ${
|
||||
wide ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="app-feedback-danger rounded-xl px-4 py-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<section
|
||||
className={`app-surface min-w-0 rounded-2xl p-4 ${
|
||||
wide
|
||||
? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<AutoRevealStickyHeader
|
||||
className="rounded-b-xl"
|
||||
disableOnDesktop={wide}
|
||||
>
|
||||
<div className="mb-4 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="app-text-muted truncate text-sm font-semibold uppercase tracking-wide">
|
||||
Проекты
|
||||
</p>
|
||||
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
|
||||
{loading ? "…" : count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Заполнить мой день"
|
||||
title="Мой день"
|
||||
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
onClick={() => setDate(today())}
|
||||
>
|
||||
<Clock3 size={16} />
|
||||
</button>
|
||||
{canCreate && (
|
||||
<ModulePrimaryAction
|
||||
className="max-sm:w-10 max-sm:px-0 [&>span:last-child]:max-sm:hidden"
|
||||
onClick={() => setCreate(true)}
|
||||
icon={<Plus size={16} />}
|
||||
label="Создать проект"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Справка о проектах"
|
||||
title="Справка"
|
||||
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
onClick={() => setHelp(true)}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={wide ? "Обычный вид" : "Широкий вид"}
|
||||
title={wide ? "Обычный вид" : "Широкий вид"}
|
||||
className="app-action-secondary hidden h-10 w-10 shrink-0 items-center justify-center rounded-lg md:inline-flex"
|
||||
onClick={() => setWide(!wide)}
|
||||
>
|
||||
{wide ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mb-4">
|
||||
<Search
|
||||
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
|
||||
size={16}
|
||||
/>
|
||||
<input
|
||||
aria-label="Поиск проектов"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
value={search}
|
||||
placeholder="Поиск по названию или заказчику"
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AutoRevealStickyHeader>
|
||||
|
||||
<div
|
||||
className={`space-y-2 ${wide ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}
|
||||
>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="app-surface-muted flex animate-pulse items-center gap-3 rounded-xl p-3"
|
||||
>
|
||||
<div className="h-10 w-10 shrink-0 rounded-lg bg-[var(--surface-tertiary)]" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-4 w-2/5 rounded bg-[var(--surface-tertiary)]" />
|
||||
<div className="h-3 w-3/5 rounded bg-[var(--surface-tertiary)]" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : projects.length ? (
|
||||
projects.map((project) => {
|
||||
const startsOn = formatDate(project.starts_on);
|
||||
const endsOn = formatDate(project.ends_on);
|
||||
const dates =
|
||||
startsOn || endsOn
|
||||
? `${startsOn || "Начало не задано"} — ${endsOn || "Срок не задан"}`
|
||||
: "Сроки не заданы";
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/projects/${project.id}`}
|
||||
className="app-surface-muted group flex min-w-0 items-center gap-3 rounded-xl border border-transparent px-3 py-3 transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] sm:px-4"
|
||||
>
|
||||
<span className="app-selected app-accent-text flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<FolderKanban size={19} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{project.name}
|
||||
</span>
|
||||
<span
|
||||
className={`app-status-pill min-h-0 px-2 py-0.5 text-[11px] ${projectStatusClass[project.status] || "app-badge"}`}
|
||||
>
|
||||
{projectStatuses[project.status] || project.status}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<UserRound size={13} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{project.manager_name}
|
||||
</span>
|
||||
</span>
|
||||
{project.customer ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Building2 size={13} className="shrink-0" />
|
||||
<span className="truncate">{project.customer}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<CalendarDays size={13} className="shrink-0" />
|
||||
<span className="truncate">{dates}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="app-text-muted shrink-0 transition group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)]"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<FolderKanban
|
||||
size={22}
|
||||
className="app-text-muted mx-auto mb-2"
|
||||
/>
|
||||
<p className="app-text-muted text-sm">
|
||||
{search
|
||||
? "Проекты не найдены"
|
||||
: "Пока нет доступных проектов. Создайте локальный проект или дождитесь приглашения в команду."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && (page > 1 || next) ? (
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-subtle)] pt-3">
|
||||
<span className="app-text-muted text-xs">Страница {page}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={buttonClass}
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className={buttonClass}
|
||||
disabled={!next}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<Panel>
|
||||
<h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-3 text-left"
|
||||
aria-expanded={externalOpen}
|
||||
aria-controls="external-project-candidates"
|
||||
aria-label={`${externalOpen ? "Свернуть" : "Развернуть"} проекты из МойСклад без привязки`}
|
||||
onClick={() => setExternalOpen((value) => !value)}
|
||||
>
|
||||
<PlugZap
|
||||
size={19}
|
||||
className="shrink-0 text-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-sm font-semibold sm:text-base">
|
||||
Проекты из МойСклад без привязки
|
||||
</span>
|
||||
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
|
||||
{externalCount}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`app-text-muted shrink-0 transition ${externalOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id="external-project-candidates"
|
||||
className={
|
||||
externalOpen
|
||||
? "space-y-3 border-t border-[var(--border-subtle)] pt-4"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<p className="app-text-muted text-sm">
|
||||
Выберите, какие записи являются рабочими проектами компании.
|
||||
</p>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search
|
||||
className="app-text-muted absolute left-3 top-3"
|
||||
size={17}
|
||||
/>
|
||||
<input
|
||||
aria-label="Поиск проектов МойСклад"
|
||||
className={`${fieldClass} pl-10`}
|
||||
value={externalSearch}
|
||||
placeholder="Найти внешний проект"
|
||||
onChange={(event) => {
|
||||
setExternalSearch(event.target.value);
|
||||
setExternalPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="app-text-muted text-xs">
|
||||
Учётные категории вроде перемещений и кредитов не создаются как
|
||||
локальные проекты автоматически.
|
||||
</p>
|
||||
{externalLoading ? (
|
||||
<p className="app-text-muted text-sm">Загрузка из МойСклад…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{externalProjects.map((external) => (
|
||||
<article
|
||||
key={`${external.source}:${external.external_id}`}
|
||||
className="app-surface-muted flex min-w-0 flex-wrap items-center gap-2 rounded-xl px-3 py-2.5 sm:flex-nowrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex min-w-40 flex-1 items-center gap-2 rounded-lg py-1 text-left"
|
||||
aria-label={`Просмотреть проект ${external.external_name}`}
|
||||
title="Просмотреть проект"
|
||||
onClick={() => void openExternalPreview(external)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium group-hover:text-[var(--accent-primary)]">
|
||||
{external.external_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block truncate text-xs">
|
||||
{external.source_name} · Просмотреть
|
||||
</span>
|
||||
</span>
|
||||
<Eye
|
||||
size={16}
|
||||
className="app-text-muted shrink-0 group-hover:text-[var(--accent-primary)]"
|
||||
/>
|
||||
</button>
|
||||
{canCreate && (
|
||||
<div className="ml-auto flex shrink-0 gap-2">
|
||||
<button
|
||||
className={`${primaryClass} px-3 py-2 text-xs`}
|
||||
onClick={() => setImportExternal(external)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Создать
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonClass} px-3 py-2 text-xs`}
|
||||
disabled={!manageableProjects.length}
|
||||
title={
|
||||
manageableProjects.length
|
||||
? "Привязать к существующему проекту"
|
||||
: "Сначала создайте локальный проект"
|
||||
}
|
||||
onClick={() => {
|
||||
setLinkExternal(external);
|
||||
setLinkProjectId(manageableProjects[0]?.id || 0);
|
||||
}}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
Привязать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!externalProjects.length && (
|
||||
<p className="app-text-muted text-sm">
|
||||
{externalSearch
|
||||
? "Непривязанные проекты не найдены."
|
||||
: "Все активные внешние проекты сопоставлены."}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="app-text-muted text-sm">
|
||||
Страница {externalPage}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={buttonClass}
|
||||
disabled={externalPage === 1}
|
||||
onClick={() => setExternalPage(externalPage - 1)}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className={buttonClass}
|
||||
disabled={!externalNext}
|
||||
onClick={() => setExternalPage(externalPage + 1)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-3 text-left"
|
||||
aria-expanded={calendarOpen}
|
||||
aria-controls="recent-project-days"
|
||||
aria-label={`${calendarOpen ? "Свернуть" : "Развернуть"} мои последние дни`}
|
||||
onClick={() => setCalendarOpen((value) => !value)}
|
||||
>
|
||||
<Clock3
|
||||
size={19}
|
||||
className="shrink-0 text-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-sm font-semibold sm:text-base">
|
||||
Мои последние дни
|
||||
</span>
|
||||
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
|
||||
{calendar.length}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`app-text-muted shrink-0 transition ${calendarOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id="recent-project-days"
|
||||
className={
|
||||
calendarOpen
|
||||
? `grid grid-cols-1 gap-2 border-t border-[var(--border-subtle)] pt-4 ${wide ? "lg:grid-cols-2" : ""}`
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{calendar.map((d) => (
|
||||
<button
|
||||
key={d.date}
|
||||
className="app-surface-muted group flex w-full min-w-0 items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)]"
|
||||
onClick={() => setDate(d.date)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{formatDate(d.date)}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-xs">
|
||||
{d.hours === null ? "Часы неизвестны" : `${d.hours} ч`}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`app-status-pill min-h-0 shrink-0 px-2 py-1 text-[11px] ${
|
||||
d.status === "confirmed"
|
||||
? "app-feedback-success"
|
||||
: "app-badge"
|
||||
}`}
|
||||
>
|
||||
{d.status === "confirmed" ? "Подтверждено" : "Черновик"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={17}
|
||||
className="app-text-muted shrink-0 transition group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)]"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{!calendar.length && (
|
||||
<p className="app-text-muted text-sm">
|
||||
Распределите первый день через кнопку «Мой день».
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{create && (
|
||||
<ProjectEditor
|
||||
onClose={() => setCreate(false)}
|
||||
onSaved={(p) => router.push(`/projects/${p.id}`)}
|
||||
/>
|
||||
)}
|
||||
{importExternal && (
|
||||
<ProjectEditor
|
||||
initial={{
|
||||
name: importExternal.external_name,
|
||||
description: `Создано из проекта «${importExternal.external_name}» в ${importExternal.source_name}.`,
|
||||
}}
|
||||
saveProject={(data) =>
|
||||
apiClient.createProjectFromExternal(importExternal, data)
|
||||
}
|
||||
onClose={() => setImportExternal(null)}
|
||||
onSaved={(project) => router.push(`/projects/${project.id}`)}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={Boolean(previewExternal)}
|
||||
onClose={() => setPreviewExternal(null)}
|
||||
title="Проект из МойСклад"
|
||||
size="md"
|
||||
>
|
||||
{externalDetailLoading ? (
|
||||
<div className="flex min-h-44 flex-col items-center justify-center gap-3">
|
||||
<span className="h-7 w-7 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
|
||||
<p className="app-text-muted text-sm">Загрузка проекта…</p>
|
||||
</div>
|
||||
) : externalDetailError ? (
|
||||
<div className="app-feedback-danger rounded-xl p-4 text-sm">
|
||||
{externalDetailError}
|
||||
</div>
|
||||
) : externalDetail ? (
|
||||
<div className="space-y-4">
|
||||
<section className="app-surface-muted flex min-w-0 items-start gap-3 rounded-xl p-4">
|
||||
<span className="app-selected app-accent-text flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<FolderKanban size={19} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="break-words font-semibold">
|
||||
{externalDetail.external_name}
|
||||
</h4>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{externalDetail.source_name}
|
||||
{externalDetail.code
|
||||
? ` · Код ${externalDetail.code}`
|
||||
: " · Код не задан"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="app-status-pill app-badge-accent min-h-0 shrink-0 px-2 py-1 text-[11px]">
|
||||
Не привязан
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<section className="app-surface-muted rounded-xl p-3">
|
||||
<div className="app-text-muted flex items-center gap-2 text-xs">
|
||||
<ReceiptText size={14} />
|
||||
Расходные документы
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{externalDetail.payment_count}
|
||||
</p>
|
||||
</section>
|
||||
<section className="app-surface-muted rounded-xl p-3">
|
||||
<div className="app-text-muted flex items-center gap-2 text-xs">
|
||||
<ShoppingCart size={14} />
|
||||
Продажи и возвраты
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{externalDetail.sale_count}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{externalDetail.payments.length ? (
|
||||
<section className="space-y-2">
|
||||
<h4 className="app-text-muted text-xs font-semibold uppercase tracking-wide">
|
||||
Оплаченные расходы
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{externalDetail.payments.map((payment) => (
|
||||
<div
|
||||
key={payment.currency}
|
||||
className="app-surface-muted flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="app-text-muted">
|
||||
{payment.count} док.
|
||||
</span>
|
||||
<strong>
|
||||
{formatAmount(payment.amount, payment.currency)}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-1">
|
||||
<h4 className="app-text-muted text-xs font-semibold uppercase tracking-wide">
|
||||
Описание
|
||||
</h4>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{externalDetail.description ||
|
||||
"Описание в МойСклад не заполнено."}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<dl className="app-surface-muted grid gap-3 rounded-xl p-3 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="app-text-muted">Изменено в источнике</dt>
|
||||
<dd className="mt-1">
|
||||
{formatDateTime(externalDetail.source_updated_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="app-text-muted">Синхронизировано</dt>
|
||||
<dd className="mt-1">
|
||||
{formatDateTime(externalDetail.synced_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0 sm:col-span-2">
|
||||
<dt className="app-text-muted">Внешний ID</dt>
|
||||
<dd className="mt-1 break-all font-mono text-[11px]">
|
||||
{externalDetail.external_id}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{canCreate ? (
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-[var(--border-subtle)] pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
disabled={!manageableProjects.length}
|
||||
onClick={() => {
|
||||
setPreviewExternal(null);
|
||||
setLinkExternal(externalDetail);
|
||||
setLinkProjectId(manageableProjects[0]?.id || 0);
|
||||
}}
|
||||
>
|
||||
<Link2 size={15} />
|
||||
Привязать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={primaryClass}
|
||||
onClick={() => {
|
||||
setPreviewExternal(null);
|
||||
setImportExternal(externalDetail);
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Создать локальный
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={Boolean(linkExternal)}
|
||||
onClose={() => {
|
||||
if (!externalBusy) setLinkExternal(null);
|
||||
}}
|
||||
title="Привязать к локальному проекту"
|
||||
>
|
||||
{linkExternal && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setExternalBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiClient.saveProjectBinding(linkProjectId, {
|
||||
source: linkExternal.source,
|
||||
external_id: linkExternal.external_id,
|
||||
external_name: linkExternal.external_name,
|
||||
object_type: "project",
|
||||
});
|
||||
removeExternalCandidate(linkExternal);
|
||||
setLinkExternal(null);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setExternalBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<strong>{linkExternal.external_name}</strong>
|
||||
<span className="app-text-muted">
|
||||
{" "}
|
||||
· {linkExternal.source_name}
|
||||
</span>
|
||||
</p>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="app-text-muted">Локальный проект</span>
|
||||
<select
|
||||
required
|
||||
className={fieldClass}
|
||||
value={linkProjectId}
|
||||
onChange={(event) =>
|
||||
setLinkProjectId(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{manageableProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="app-text-muted text-xs">
|
||||
Платежи и другие данные внешнего проекта будут учитываться в
|
||||
выбранном локальном проекте. Названия не объединяются.
|
||||
</p>
|
||||
<button
|
||||
className={primaryClass}
|
||||
disabled={externalBusy || !linkProjectId}
|
||||
>
|
||||
{externalBusy ? "Привязка…" : "Привязать"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
{date && (
|
||||
<DailyTimeAllocationModal
|
||||
date={date}
|
||||
onClose={() => setDate(null)}
|
||||
onSaved={() => void refreshCalendar()}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={help}
|
||||
onClose={() => setHelp(false)}
|
||||
title="Проекты и участие"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p>
|
||||
Создавайте проекты внутри портала, добавляйте команду и этапы.
|
||||
Непривязанные проекты МойСклад показаны в очереди на главной
|
||||
странице: из записи можно создать локальный проект или привязать
|
||||
её к существующему. Руководитель также может связать объекты
|
||||
других подключённых сервисов. Совпадение названий не объединяет
|
||||
записи автоматически.
|
||||
</p>
|
||||
<p>
|
||||
В «Мой день» распределите время между проектами и общей
|
||||
деятельностью. Для подтверждения нужно 100%. Часы берутся из
|
||||
успешного анализа посещаемости; без него часы неизвестны. Проценты
|
||||
не изменяют зарплату или баллы выработки.
|
||||
</p>
|
||||
<p>
|
||||
Напоминание появляется один раз в день при входе после
|
||||
отработанной смены, если за последние 14 дней есть незаполненный
|
||||
день и участие в проекте. Оно не перекрывает другое модальное
|
||||
окно. Можно отложить на два часа.
|
||||
</p>
|
||||
<p>
|
||||
Участник видит свои часы, руководитель — часы команды. Финансовые
|
||||
суммы доступны только с правами финансовой аналитики. Завершённые
|
||||
проекты сохраняют историю.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import { FolderKanban, AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -21,6 +21,8 @@ import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
import type { GuestVisit, GuestVisitComment } from "@/types/api";
|
||||
|
||||
import { DailyTimeAllocationPrompt } from "@/components/projects/DailyTimeAllocationPrompt";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
desktopWideMode?: boolean;
|
||||
@ -69,6 +71,7 @@ const navItems = [
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
// Раздел сохранён для дальнейшей разработки; интеграции доступны в Django admin.
|
||||
// { href: "/products", label: "Товары", icon: PackageSearch },
|
||||
{ href: "/projects", label: "Проекты", icon: FolderKanban },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false },
|
||||
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
|
||||
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const },
|
||||
@ -828,6 +831,7 @@ export function AppShell({
|
||||
|
||||
{!standaloneAppMode ? <CalendarModals {...cal} /> : null}
|
||||
<GuestInfoRequestPrompt userId={user.id} />
|
||||
{!standaloneAppMode && <DailyTimeAllocationPrompt userId={user.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -76,12 +76,15 @@ export function AnalyticsHelpContent({ tab }: { tab: AnalyticsTab }) {
|
||||
</>}
|
||||
{tab === "projects" && <>
|
||||
<p>Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж.</p>
|
||||
<p> Проект платежа берётся из поля «Проект» в МойСклад. Платежи без этой
|
||||
связи остаются в группе «Без проекта». Изменяйте импортированные
|
||||
<p>Стоимость труда = подтверждённые часы участия × стоимость часа. Пока зарплатный период открыт, сумма предварительная: используется текущий расчёт, а до его появления — утверждённая ставка и норма часов. После закрытия периода сумма становится точной и рассчитывается из итогового начисления сотруднику с премиями, доплатами и корректировками. Если расчётного периода, нормы или итоговых данных нет, соответствующая часть помечается как неоценённая и не считается нулём.</p>
|
||||
<p>Проектом отчёта служит локальная карточка портала. Платежи, продажи и
|
||||
расходы подключённых сервисов объединяются по точным внешним привязкам.
|
||||
Записи без локальной связи остаются в группе «Без привязки». Изменяйте импортированные
|
||||
документы в МойСклад. Синхронизацией управляет администратор в админке,
|
||||
в разделе «Подключения интеграций». В журнал добавляйте только
|
||||
отсутствующие в МойСклад факты.
|
||||
Архивные проекты сохраняются в отчётах. Отсутствие загруженных
|
||||
Подтверждённое распределение рабочего времени показывается в часах и
|
||||
денежной стоимости. Архивные проекты сохраняются в отчётах. Отсутствие загруженных
|
||||
документов не подтверждает отсутствие реальных расходов.
|
||||
</p>
|
||||
</>}
|
||||
|
||||
@ -125,7 +125,7 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
|
||||
`${params.date_from.split("-").reverse().join(".")} — ${params.date_to.split("-").reverse().join(".")}`,
|
||||
params.currency,
|
||||
query.source ? options?.sources.find((source) => String(source.id) === query.source)?.name : null,
|
||||
query.project === "unassigned" ? "Без проекта" : query.project
|
||||
query.project === "unassigned" ? "Без привязки" : query.project
|
||||
? options?.projects.find((project) => String(project.id) === query.project)?.name : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
const actionsTarget = typeof document !== "undefined" ? document.getElementById(actionsTargetId) : null;
|
||||
@ -247,11 +247,12 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
|
||||
}
|
||||
>
|
||||
<option value="">Все проекты</option>
|
||||
<option value="unassigned">Без проекта</option>
|
||||
<option value="unassigned">Без привязки</option>
|
||||
{options?.projects
|
||||
.filter(
|
||||
(p) =>
|
||||
!draft.source || String(p.source_id) === draft.source,
|
||||
!draft.source ||
|
||||
p.source_ids.includes(Number(draft.source)),
|
||||
)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
@ -299,7 +300,7 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
|
||||
{tab === "journal" && options ? (
|
||||
<ReportingJournal
|
||||
key={JSON.stringify(params)}
|
||||
params={params}
|
||||
params={{ ...params, source: undefined }}
|
||||
options={options}
|
||||
onChanged={() => setReport(null)}
|
||||
/>
|
||||
|
||||
@ -13,6 +13,13 @@ import { AnalyticsRecordCard } from "./AnalyticsRecordCard";
|
||||
import { DatasetChart } from "./DatasetChart";
|
||||
import { AnalyticsListSection } from "./AnalyticsListSection";
|
||||
|
||||
const laborStatusLabel = (status: unknown) => ({
|
||||
exact: "Точная — период закрыт",
|
||||
preliminary: "Предварительная — период открыт",
|
||||
mixed: "Смешанная — есть открытые периоды",
|
||||
unavailable: "Нет оценки",
|
||||
}[String(status)] || "Нет оценки");
|
||||
|
||||
export function ProjectsReport({
|
||||
report,
|
||||
params,
|
||||
@ -24,32 +31,57 @@ export function ProjectsReport({
|
||||
const unit = report.currency;
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-3">
|
||||
{[
|
||||
["Оплачено по МойСклад", data.summary.paid],
|
||||
["Из них без проекта", data.summary.unassigned_paid],
|
||||
["Ручные выплаты", data.summary.manual_paid],
|
||||
["Общие расходы по журналу", data.summary.overhead],
|
||||
].map(([label, value]) => (
|
||||
["Оплачено по источникам", data.summary.paid, unit],
|
||||
["Без привязки", data.summary.unassigned_paid, unit],
|
||||
["Ручные выплаты", data.summary.manual_paid, unit],
|
||||
["Общие расходы", data.summary.overhead, unit],
|
||||
["Подтверждённое время", data.summary.labor_hours, "ч"],
|
||||
["Стоимость труда", data.summary.labor_cost, unit],
|
||||
["Участников", data.summary.people_count, "чел."],
|
||||
].map(([label, value, valueUnit]) => (
|
||||
<section key={String(label)} className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<p className="app-text-muted text-sm">{label}</p>
|
||||
<p className="mt-3 break-words text-2xl font-semibold tabular-nums">
|
||||
{formatMetric(value, 2)}{" "}
|
||||
<span className="app-text-muted text-sm">{unit}</span>
|
||||
<span className="app-text-muted text-sm">{valueUnit}</span>
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<p className="app-surface rounded-xl px-4 py-3 text-sm">
|
||||
<strong>{laborStatusLabel(data.summary.labor_cost_status)}</strong>
|
||||
{Number(data.summary.preliminary_labor_hours || 0) > 0 && (
|
||||
<span className="app-text-muted">
|
||||
{" "}Предварительно: {formatMetric(data.summary.preliminary_labor_cost, 2)} {unit}
|
||||
{Number(data.summary.exact_labor_hours || 0) > 0
|
||||
? ` · точно по закрытым периодам: ${formatMetric(data.summary.exact_labor_cost, 2)} ${unit}`
|
||||
: ""}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{!data.summary.labor_cost_complete && (
|
||||
<p className="app-feedback-warning rounded-xl p-3 text-sm">
|
||||
Стоимость труда рассчитана не полностью: {formatMetric(data.summary.unpriced_labor_hours, 2)} ч
|
||||
без предварительной базы или точного расчёта закрытого периода; дней без достоверных часов —{" "}
|
||||
{formatMetric(data.summary.unknown_hours_days, 0)}.
|
||||
</p>
|
||||
)}
|
||||
<div className="analytics-report-grid">
|
||||
<ReportTable
|
||||
featured
|
||||
title="Расходы и продажи по проектам"
|
||||
rows={data.rows}
|
||||
rows={data.rows.map((row) => ({
|
||||
...row,
|
||||
labor_cost_status: laborStatusLabel(row.labor_cost_status),
|
||||
}))}
|
||||
filename={`projects-${report.period.from}-${report.period.to}-${unit}`}
|
||||
note="Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж."
|
||||
note="Локальная карточка объединяет продажи, расходы и платежи всех связанных внешних проектов. До закрытия зарплатного периода стоимость труда предварительная. После закрытия она рассчитывается по итоговому начислению с премиями и доплатами."
|
||||
columns={[
|
||||
{ key: "name", label: "Проект", text: true },
|
||||
{ key: "paid", additive: true, label: `Оплачено в МойСклад, ${unit}`, digits: 2 },
|
||||
{ key: "external_project_count", additive: true, label: "Связей", digits: 0 },
|
||||
{ key: "paid", additive: true, label: `Оплачено по источникам, ${unit}`, digits: 2 },
|
||||
{ key: "payment_count", additive: true, label: "Платежей", digits: 0 },
|
||||
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
|
||||
{
|
||||
@ -69,20 +101,50 @@ export function ProjectsReport({
|
||||
label: `Прибыль продаж по учтённым расходам, ${unit}`,
|
||||
digits: 2,
|
||||
},
|
||||
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
|
||||
{ key: "labor_cost_status", label: "Статус суммы", text: true },
|
||||
{ key: "project_result", additive: true, label: `Результат после труда и общих расходов, ${unit}`, digits: 2 },
|
||||
]}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Выплаты по месяцам"
|
||||
rows={data.monthly}
|
||||
title="Участие сотрудников по проектам"
|
||||
rows={data.rows.filter((row) => row.id !== "unassigned").map((row) => ({
|
||||
...row,
|
||||
labor_cost_status: laborStatusLabel(row.labor_cost_status),
|
||||
}))}
|
||||
filename={`project-labor-${report.period.from}-${report.period.to}`}
|
||||
note="Часы рассчитываются из подтверждённого распределения дня. Сумма открытого периода предварительная; сумма закрытого периода использует итоговое начисление сотруднику, включая премии и доплаты. Пропуски учитываются отдельно."
|
||||
columns={[
|
||||
{ key: "name", label: "Проект", text: true },
|
||||
{ key: "labor_hours", additive: true, label: "Трудозатраты, ч", digits: 2 },
|
||||
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
|
||||
{ key: "labor_cost_status", label: "Статус суммы", text: true },
|
||||
{ key: "labor_hour_cost", label: `Средняя стоимость часа, ${unit}`, digits: 2 },
|
||||
{ key: "unpriced_labor_hours", additive: true, label: "Часы без оценки", digits: 2 },
|
||||
{ key: "people_count", label: "Участников", digits: 0 },
|
||||
{ key: "allocation_days", additive: true, label: "Дней учёта", digits: 0 },
|
||||
{ key: "unknown_hours_days", additive: true, label: "Дней без часов", digits: 0 },
|
||||
]}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Движения и трудозатраты по месяцам"
|
||||
rows={data.monthly.map((row) => ({
|
||||
...row,
|
||||
labor_cost_status: laborStatusLabel(row.labor_cost_status),
|
||||
}))}
|
||||
filename={`project-months-${report.period.from}-${report.period.to}-${unit}`}
|
||||
columns={[
|
||||
{ key: "name", label: "Месяц", text: true },
|
||||
{ key: "paid", additive: true, label: `МойСклад, ${unit}`, digits: 2 },
|
||||
{ key: "paid", additive: true, label: `Источники, ${unit}`, digits: 2 },
|
||||
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
|
||||
{ key: "labor_hours", additive: true, label: "Трудозатраты, ч", digits: 2 },
|
||||
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
|
||||
{ key: "labor_cost_status", label: "Статус суммы", text: true },
|
||||
{ key: "unpriced_labor_hours", additive: true, label: "Часы без оценки", digits: 2 },
|
||||
]}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Статьи оплаченных расходов МойСклад"
|
||||
title="Статьи оплаченных расходов"
|
||||
rows={data.categories}
|
||||
filename={`project-expenses-${report.period.from}-${report.period.to}-${unit}`}
|
||||
columns={[
|
||||
@ -151,12 +213,13 @@ function ProjectPayments({ params }: { params: ReportParams }) {
|
||||
<div className="analytics-records-compact space-y-2">
|
||||
{data.results.map((payment) => <AnalyticsRecordCard key={payment.id}
|
||||
title={`${payment.document_type === "cashout" ? "Расходный ордер" : "Платёж"} ${payment.number}`}
|
||||
subtitle={`${new Date(payment.occurred_at).toLocaleDateString("ru-RU")} · ${payment.project_name || "Без проекта"}`}
|
||||
subtitle={`${new Date(payment.occurred_at).toLocaleDateString("ru-RU")} · ${payment.project_name || "Без привязки"}`}
|
||||
summary={`${formatMetric(payment.amount, 2)} ${payment.currency}`}
|
||||
fields={[
|
||||
{ label: "Дата", value: new Date(payment.occurred_at).toLocaleDateString("ru-RU") },
|
||||
{ label: "Документ", value: payment.number || "—" },
|
||||
{ label: "Проект", value: payment.project_name || "Без проекта" },
|
||||
{ label: "Локальный проект", value: payment.project_name || "Без привязки" },
|
||||
{ label: "Проект источника", value: payment.external_project_name || "Не указан" },
|
||||
{ label: "Статья", value: payment.expense_category || "Статья не указана" },
|
||||
{ label: "Получатель", value: payment.payee || "—" },
|
||||
{ label: "Назначение", value: payment.description || "—" },
|
||||
@ -197,7 +260,12 @@ function ProjectPayments({ params }: { params: ReportParams }) {
|
||||
</p>
|
||||
</td>
|
||||
<td className="min-w-36 px-3 py-3">
|
||||
{p.project_name || "Без проекта"}
|
||||
{p.project_name || "Без привязки"}
|
||||
{p.external_project_name && (
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{p.external_project_name} · {p.source_name}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="min-w-44 px-3 py-3">
|
||||
{p.expense_category || "Статья не указана"}
|
||||
|
||||
@ -107,7 +107,7 @@ function EntryForm({
|
||||
name="project"
|
||||
defaultValue={project === "unassigned" ? "" : project || ""}
|
||||
>
|
||||
<option value="">Без проекта</option>
|
||||
<option value="">Без локального проекта</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
@ -355,7 +355,7 @@ export function ReportingJournal({
|
||||
{ label: "Дата", value: entry.date },
|
||||
{ label: "Документ", value: entry.reference || "—" },
|
||||
{ label: "Вид", value: reportingKinds[entry.kind] },
|
||||
{ label: "Проект", value: entry.project_name || "Без проекта" },
|
||||
{ label: "Проект", value: entry.project_name || "Без локального проекта" },
|
||||
{ label: "Товар", value: entry.product_name || "—" },
|
||||
{ label: "Сотрудник", value: entry.employee_name || "—" },
|
||||
{ label: "Назначение", value: entry.description || "—" },
|
||||
@ -398,7 +398,7 @@ export function ReportingJournal({
|
||||
<td className="min-w-56 px-3 py-3">
|
||||
<p>{reportingKinds[entry.kind]}</p>
|
||||
<p className="app-text-muted mt-1">
|
||||
{entry.project_name || "Без проекта"}
|
||||
{entry.project_name || "Без локального проекта"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1">
|
||||
{entry.product_name ||
|
||||
|
||||
@ -51,6 +51,8 @@ function currentEntryForDate(
|
||||
return entries.find((entry) => entry.work_date === workDate) || null;
|
||||
}
|
||||
|
||||
import { DailyTimeAllocationModal } from "@/components/projects/DailyTimeAllocationModal";
|
||||
|
||||
export function PayrollDailyWorkSection({
|
||||
compactPeriodSelector = false,
|
||||
detailsTarget,
|
||||
@ -65,6 +67,7 @@ export function PayrollDailyWorkSection({
|
||||
const [workspace, setWorkspace] = useState<PayrollOwnDailyWorkWorkspace | null>(null);
|
||||
const [selectedPeriodId, setSelectedPeriodId] = useState<number | null>(null);
|
||||
const [workDate, setWorkDate] = useState("");
|
||||
const [projectDayOpen, setProjectDayOpen] = useState(false);
|
||||
const [actualPoints, setActualPoints] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -447,6 +450,8 @@ export function PayrollDailyWorkSection({
|
||||
{periodSelector}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end"><button type="button" className="app-action-secondary rounded-xl px-3 py-2 text-sm" disabled={!workDate} onClick={() => setProjectDayOpen(true)}>Распределить день по проектам</button></div>
|
||||
{projectDayOpen && <DailyTimeAllocationModal date={workDate} onClose={() => setProjectDayOpen(false)} />}
|
||||
<section className={cardClassName}>
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
353
frontend/src/components/projects/DailyTimeAllocationModal.tsx
Normal file
353
frontend/src/components/projects/DailyTimeAllocationModal.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Copy } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { ProjectDay } from "@/types/projects";
|
||||
import {
|
||||
Field,
|
||||
fieldClass,
|
||||
buttonClass,
|
||||
primaryClass,
|
||||
activityLabels,
|
||||
today,
|
||||
number,
|
||||
errorText,
|
||||
} from "./shared";
|
||||
export function DailyTimeAllocationModal({
|
||||
date,
|
||||
onClose,
|
||||
onSaved,
|
||||
onSnooze,
|
||||
}: {
|
||||
date: string;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
onSnooze?: () => Promise<void>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(date),
|
||||
[day, setDay] = useState<ProjectDay | null>(null),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[dirty, setDirty] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const timer = setTimeout(() => {
|
||||
setDay(null);
|
||||
setError("");
|
||||
apiClient
|
||||
.getProjectDay(selected)
|
||||
.then((result) => {
|
||||
if (live) {
|
||||
setDay(result);
|
||||
setDirty(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
});
|
||||
}, 0);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [selected]);
|
||||
const total =
|
||||
day?.allocations.reduce(
|
||||
(sum, a) => sum + Math.round(Number(a.percent || 0) * 100),
|
||||
0,
|
||||
) || 0;
|
||||
const change = (update: Partial<ProjectDay>) => {
|
||||
if (day) {
|
||||
setDay({ ...day, ...update });
|
||||
setDirty(true);
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
if (
|
||||
!busy &&
|
||||
(!dirty || window.confirm("Закрыть без сохранения распределения?"))
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
const save = async (status: "draft" | "confirmed") => {
|
||||
if (!day) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await apiClient.saveProjectDay({ ...day, status });
|
||||
setDay(saved);
|
||||
setDirty(false);
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal isOpen onClose={close} title="На что ушёл рабочий день?" size="lg">
|
||||
<div className="space-y-4">
|
||||
<Field label="Рабочий день">
|
||||
<input
|
||||
type="date"
|
||||
max={today()}
|
||||
className={fieldClass}
|
||||
value={selected}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
(!dirty || window.confirm("Сменить дату без сохранения?"))
|
||||
) {
|
||||
setDay(null);
|
||||
setSelected(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!day && !error && <p>Загрузка…</p>}
|
||||
{day && (
|
||||
<>
|
||||
<p className="app-text-muted text-sm">
|
||||
{day.hours === null
|
||||
? "Продолжительность дня пока неизвестна. Проценты можно сохранить; часы останутся неподтверждёнными."
|
||||
: `По посещаемости: ${number(day.hours)} ч. Распределение не меняет зарплату и баллы выработки.`}
|
||||
</p>
|
||||
{day.attendance_changed && (
|
||||
<p className="text-sm text-amber-400">
|
||||
Посещаемость изменилась. При сохранении часы будут обновлены;
|
||||
предыдущая версия сохранится в истории.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const calendar = await apiClient.getProjectCalendar();
|
||||
const previous = calendar.find(
|
||||
(d) => d.date < selected && d.status === "confirmed",
|
||||
);
|
||||
if (!previous)
|
||||
throw new Error(
|
||||
"Нет предыдущего подтверждённого дня за последние 90 дней",
|
||||
);
|
||||
const old = await apiClient.getProjectDay(previous.date);
|
||||
const allowed = new Set(day.projects.map((p) => p.id));
|
||||
const rows = old.allocations.filter(
|
||||
(a) => !a.project || allowed.has(a.project),
|
||||
);
|
||||
change({ allocations: rows });
|
||||
if (rows.length !== old.allocations.length)
|
||||
setError(
|
||||
"Недоступные на эту дату проекты пропущены. Проверьте остаток.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} />
|
||||
Повторить прошлый рабочий день
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
{day.allocations.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="app-surface-muted grid grid-cols-[minmax(0,1fr)_82px_32px] gap-2 rounded-xl p-3"
|
||||
>
|
||||
<select
|
||||
aria-label={`Проект или деятельность ${i + 1}`}
|
||||
className={fieldClass}
|
||||
value={a.project ? `p:${a.project}` : a.activity}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
change({
|
||||
allocations: day.allocations.map((row, j) =>
|
||||
i === j
|
||||
? {
|
||||
...row,
|
||||
project: value.startsWith("p:")
|
||||
? Number(value.slice(2))
|
||||
: null,
|
||||
activity: value.startsWith("p:") ? "" : value,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Выберите</option>
|
||||
<optgroup label="Мои проекты">
|
||||
{day.projects.map((p) => (
|
||||
<option key={p.id} value={`p:${p.id}`}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
{a.project &&
|
||||
!day.projects.some((p) => p.id === a.project) && (
|
||||
<option value={`p:${a.project}`}>
|
||||
{a.project_name || `Проект #${a.project}`} (история)
|
||||
</option>
|
||||
)}
|
||||
</optgroup>
|
||||
<optgroup label="Общая деятельность">
|
||||
{Object.entries(activityLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
<input
|
||||
aria-label={`Процент времени ${i + 1}`}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="0.01"
|
||||
disabled={busy}
|
||||
className={fieldClass}
|
||||
value={a.percent}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
allocations: day.allocations.map((row, j) =>
|
||||
i === j ? { ...row, percent: e.target.value } : row,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Удалить строку ${i + 1}`}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
change({
|
||||
allocations: day.allocations.filter((_, j) => i !== j),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<div className="col-span-3 flex flex-wrap items-center gap-2 text-xs app-text-muted">
|
||||
{[25, 50, 75, 100].map((v) => (
|
||||
<button
|
||||
disabled={busy}
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded px-2 py-1 hover:bg-white/10"
|
||||
onClick={() =>
|
||||
change({
|
||||
allocations: day.allocations.map((row, j) =>
|
||||
i === j ? { ...row, percent: String(v) } : row,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{v}%
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto">
|
||||
{day.hours === null
|
||||
? "Часы неизвестны"
|
||||
: `${number((Number(day.hours) * Number(a.percent || 0)) / 100)} ч`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className={buttonClass}
|
||||
disabled={busy || day.allocations.length >= 50}
|
||||
onClick={() =>
|
||||
change({
|
||||
allocations: [
|
||||
...day.allocations,
|
||||
{
|
||||
project: null,
|
||||
activity: "",
|
||||
percent: String(Math.max(0, 100 - total / 100)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить строку
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Распределено</span>
|
||||
<strong className={total > 10000 ? "text-red-400" : ""}>
|
||||
{number(total / 100)}% / 100%
|
||||
</strong>
|
||||
</div>
|
||||
<div className="app-surface-muted h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-[var(--accent-primary)]"
|
||||
style={{ width: `${Math.min(100, total / 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Комментарий (необязательно)">
|
||||
<textarea
|
||||
className={fieldClass}
|
||||
maxLength={2000}
|
||||
value={day.note}
|
||||
onChange={(e) => change({ note: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
disabled={busy || total > 10000}
|
||||
className={buttonClass}
|
||||
onClick={() => void save("draft")}
|
||||
>
|
||||
Сохранить черновик
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || total !== 10000}
|
||||
className={primaryClass}
|
||||
onClick={() => void save("confirmed")}
|
||||
>
|
||||
Подтвердить день
|
||||
</button>
|
||||
{onSnooze && (
|
||||
<button
|
||||
disabled={busy}
|
||||
className={buttonClass}
|
||||
onClick={async () => {
|
||||
if (dirty && !window.confirm("Отложить без сохранения?"))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSnooze();
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Напомнить через 2 часа
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { DailyTimeAllocationModal } from "./DailyTimeAllocationModal";
|
||||
export function DailyTimeAllocationPrompt({ userId }: { userId: number }) {
|
||||
const [date, setDate] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true,
|
||||
inFlight = false;
|
||||
let pending: string | null = null;
|
||||
const check = async () => {
|
||||
if (
|
||||
!live ||
|
||||
inFlight ||
|
||||
document.visibilityState !== "visible" ||
|
||||
document.querySelector('[role="dialog"]') ||
|
||||
["INPUT", "TEXTAREA", "SELECT"].includes(
|
||||
document.activeElement?.tagName || "",
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (pending) {
|
||||
setDate(pending);
|
||||
pending = null;
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
const r = await apiClient.claimProjectReminder();
|
||||
if (live && r.date) {
|
||||
if (
|
||||
document.querySelector('[role="dialog"]') ||
|
||||
document.visibilityState !== "visible" ||
|
||||
["INPUT", "TEXTAREA", "SELECT"].includes(
|
||||
document.activeElement?.tagName || "",
|
||||
)
|
||||
)
|
||||
pending = r.date;
|
||||
else setDate(r.date);
|
||||
}
|
||||
} catch {
|
||||
/* Retry on the next scheduled check; foreground UI remains usable. */
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(() => void check(), 60000);
|
||||
const first = window.setTimeout(() => void check(), 15000);
|
||||
document.addEventListener("visibilitychange", check);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(timer);
|
||||
clearTimeout(first);
|
||||
document.removeEventListener("visibilitychange", check);
|
||||
};
|
||||
}, [userId]);
|
||||
return date ? (
|
||||
<DailyTimeAllocationModal
|
||||
date={date}
|
||||
onClose={() => setDate(null)}
|
||||
onSnooze={async () => {
|
||||
await apiClient.claimProjectReminder(true);
|
||||
setDate(null);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
172
frontend/src/components/projects/ProjectEditor.tsx
Normal file
172
frontend/src/components/projects/ProjectEditor.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import type { LocalProject, ProjectInput } from "@/types/projects";
|
||||
import {
|
||||
Field,
|
||||
fieldClass,
|
||||
primaryClass,
|
||||
projectStatuses,
|
||||
errorText,
|
||||
} from "./shared";
|
||||
export function ProjectEditor({
|
||||
project,
|
||||
initial,
|
||||
saveProject,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
project?: LocalProject;
|
||||
initial?: Partial<ProjectInput>;
|
||||
saveProject?: (data: ProjectInput) => Promise<LocalProject>;
|
||||
onClose: () => void;
|
||||
onSaved: (p: LocalProject) => void;
|
||||
}) {
|
||||
const { user } = useUser();
|
||||
const [form, setForm] = useState<ProjectInput>({
|
||||
name: project?.name || initial?.name || "",
|
||||
description: project?.description || initial?.description || "",
|
||||
customer: project?.customer || initial?.customer || "",
|
||||
manager: project?.manager || initial?.manager || user?.id || 0,
|
||||
status: project?.status || initial?.status || "planned",
|
||||
starts_on: project?.starts_on || initial?.starts_on || null,
|
||||
ends_on: project?.ends_on || initial?.ends_on || null,
|
||||
});
|
||||
const [people, setPeople] = useState<{ id: number; name: string }[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
apiClient
|
||||
.getProjectOptions()
|
||||
.then((v) => {
|
||||
if (live) setPeople(v.employees);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
title={project ? "Редактировать проект" : "Новый проект"}
|
||||
size="lg"
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
onSaved(
|
||||
saveProject
|
||||
? await saveProject(form)
|
||||
: await apiClient.saveLocalProject(form, project?.id),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label="Название">
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
maxLength={255}
|
||||
className={fieldClass}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Цель и описание">
|
||||
<textarea
|
||||
className={fieldClass}
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Заказчик">
|
||||
<input
|
||||
maxLength={255}
|
||||
className={fieldClass}
|
||||
value={form.customer}
|
||||
onChange={(e) => setForm({ ...form, customer: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Руководитель">
|
||||
<select
|
||||
required
|
||||
className={fieldClass}
|
||||
value={form.manager}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, manager: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="">Выберите сотрудника</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Статус">
|
||||
<select
|
||||
className={fieldClass}
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||
>
|
||||
{Object.entries(projectStatuses).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Начало">
|
||||
<input
|
||||
type="date"
|
||||
className={fieldClass}
|
||||
value={form.starts_on || ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, starts_on: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Окончание">
|
||||
<input
|
||||
type="date"
|
||||
className={fieldClass}
|
||||
min={form.starts_on || undefined}
|
||||
value={form.ends_on || ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, ends_on: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button disabled={busy} className={primaryClass}>
|
||||
{busy ? "Сохранение…" : "Сохранить проект"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FolderKanban } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { LocalProject } from "@/types/projects";
|
||||
import { DailyTimeAllocationModal } from "./DailyTimeAllocationModal";
|
||||
import { buttonClass, today, projectStatuses, errorText } from "./shared";
|
||||
export function ProjectParticipationProfileSection() {
|
||||
const [projects, setProjects] = useState<LocalProject[]>([]),
|
||||
[count, setCount] = useState(0),
|
||||
[open, setOpen] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
apiClient
|
||||
.getLocalProjects("", 1, true)
|
||||
.then((r) => {
|
||||
if (live) {
|
||||
setProjects(r.results.slice(0, 5));
|
||||
setCount(r.count);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(errorText(e));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<details>
|
||||
<summary className="flex cursor-pointer items-center gap-3 text-sm font-semibold">
|
||||
<FolderKanban size={18} />
|
||||
<span className="flex-1">Мои проекты</span>
|
||||
<span className="app-badge app-badge-accent px-2 py-0.5">
|
||||
{count}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3">
|
||||
{error && (
|
||||
<p role="alert" className="text-red-400 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{projects.map((p) => (
|
||||
<Link
|
||||
className="app-surface-muted flex items-center justify-between gap-3 rounded-xl p-3 text-sm"
|
||||
href={`/projects/${p.id}`}
|
||||
key={p.id}
|
||||
>
|
||||
<span className="min-w-0 break-words">{p.name}</span>
|
||||
<span className="app-text-muted text-xs">
|
||||
{projectStatuses[p.status]}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{!projects.length && !error && (
|
||||
<p className="app-text-muted text-sm">
|
||||
Вы пока не участвуете в проектах.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={buttonClass} onClick={() => setOpen(true)}>
|
||||
Распределить мой день
|
||||
</button>
|
||||
<Link href="/projects" className={buttonClass}>
|
||||
Открыть проекты
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{open && (
|
||||
<DailyTimeAllocationModal
|
||||
date={today()}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/projects/shared.tsx
Normal file
50
frontend/src/components/projects/shared.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
export const projectStatuses: Record<string, string> = {
|
||||
planned: "Планируется",
|
||||
active: "В работе",
|
||||
paused: "Приостановлен",
|
||||
completed: "Завершён",
|
||||
archived: "Архив",
|
||||
};
|
||||
export const activityLabels: Record<string, string> = {
|
||||
department: "Работа отдела",
|
||||
training: "Обучение",
|
||||
support: "Поддержка",
|
||||
};
|
||||
export const fieldClass =
|
||||
"app-input w-full min-w-0 rounded-xl px-3 py-2.5 text-sm";
|
||||
export const buttonClass =
|
||||
"app-action-secondary inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm";
|
||||
export const primaryClass =
|
||||
"app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm";
|
||||
export const today = () =>
|
||||
new Date().toLocaleDateString("en-CA", { timeZone: "Europe/Moscow" });
|
||||
export const number = (n: string | number | null) =>
|
||||
n === null
|
||||
? "—"
|
||||
: new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(
|
||||
Number(n),
|
||||
);
|
||||
export const errorText = (e: unknown) =>
|
||||
e instanceof Error ? e.message : "Не удалось выполнить действие";
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block min-w-0 space-y-1 text-sm">
|
||||
<span className="app-text-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export function Panel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="app-surface min-w-0 space-y-4 rounded-2xl p-4 sm:p-5">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronRight, Link2, Loader2, Plus } from "lucide-react";
|
||||
import { ChevronRight, Link2, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
@ -24,6 +24,8 @@ type RelatedTaskLinksProps = {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
onLinked?: () => void;
|
||||
canLink?: boolean;
|
||||
unlinkTask?: (linkId: number) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const taskPriorityOptions: { value: TaskPriority; label: string }[] = [
|
||||
@ -64,6 +66,8 @@ export function RelatedTaskLinks({
|
||||
open = false,
|
||||
onClose,
|
||||
onLinked,
|
||||
canLink = true,
|
||||
unlinkTask,
|
||||
}: RelatedTaskLinksProps) {
|
||||
const [linkedTasks, setLinkedTasks] = useState<TaskCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -84,6 +88,7 @@ export function RelatedTaskLinks({
|
||||
const [labelIds, setLabelIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unlinkingId, setUnlinkingId] = useState<number | null>(null);
|
||||
|
||||
const selectedBoard = boards.find((board) => board.id === boardId) || null;
|
||||
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
|
||||
@ -540,14 +545,16 @@ export function RelatedTaskLinks({
|
||||
Связанные задачи ({linkedTasks.length})
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openLinkModal()}
|
||||
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Связать
|
||||
</button>
|
||||
{canLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openLinkModal()}
|
||||
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Связать
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@ -557,21 +564,43 @@ export function RelatedTaskLinks({
|
||||
) : linkedTasks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{linkedTasks.map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.id}
|
||||
task={{
|
||||
id: task.id,
|
||||
link_id: task.link_id,
|
||||
title: task.title,
|
||||
board_id: task.board,
|
||||
board_name: task.board_name || "",
|
||||
column_color: task.column_color,
|
||||
priority: task.priority,
|
||||
priority_display: task.priority_display,
|
||||
can_open: task.can_open,
|
||||
}}
|
||||
maxTitleClassName="max-w-56"
|
||||
/>
|
||||
<span key={task.id} className="inline-flex max-w-full items-center gap-1">
|
||||
<TaskLinkPill
|
||||
task={{
|
||||
id: task.id,
|
||||
link_id: task.link_id,
|
||||
title: task.title,
|
||||
board_id: task.board,
|
||||
board_name: task.board_name || "",
|
||||
column_color: task.column_color,
|
||||
priority: task.priority,
|
||||
priority_display: task.priority_display,
|
||||
can_open: task.can_open,
|
||||
}}
|
||||
maxTitleClassName="max-w-56"
|
||||
/>
|
||||
{canLink && unlinkTask && task.link_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-icon-button"
|
||||
aria-label={`Убрать связь с задачей ${task.title}`}
|
||||
disabled={unlinkingId === task.link_id}
|
||||
onClick={() => void (async () => {
|
||||
setUnlinkingId(task.link_id!);
|
||||
try {
|
||||
await unlinkTask(task.link_id!);
|
||||
await refreshLinkedTasks();
|
||||
} catch (unlinkError) {
|
||||
toast.error(unlinkError instanceof Error ? unlinkError.message : "Не удалось убрать связь");
|
||||
} finally {
|
||||
setUnlinkingId(null);
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{unlinkingId === task.link_id ? <Loader2 className="animate-spin" size={14} /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -18,6 +18,7 @@ import { createGuestsApi } from './guests';
|
||||
import { createTasksApi } from './tasks';
|
||||
import { createFinanceApi } from './finance';
|
||||
import { createCommerceApi } from './commerce';
|
||||
import { createProjectsApi } from './projects';
|
||||
import { createAnalyticsApi } from './analytics';
|
||||
|
||||
const base = new ApiClientBase();
|
||||
@ -41,6 +42,7 @@ export const apiClient = Object.assign(base, {
|
||||
...createFinanceApi(req),
|
||||
...createCommerceApi(req),
|
||||
...createAnalyticsApi(req),
|
||||
...createProjectsApi(req),
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
|
||||
174
frontend/src/lib/api/projects.ts
Normal file
174
frontend/src/lib/api/projects.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import type { RequestFn } from "./utils";
|
||||
import { buildQuery } from "./utils";
|
||||
import type {
|
||||
LocalProject,
|
||||
ProjectInput,
|
||||
ProjectMember,
|
||||
ProjectStage,
|
||||
ProjectResource,
|
||||
ProjectBinding,
|
||||
ExternalProjectOption,
|
||||
ProjectDay,
|
||||
ProjectTimeReport,
|
||||
ProjectFinance,
|
||||
ProjectEvent,
|
||||
ProjectOption,
|
||||
ProjectBoardLink,
|
||||
ExternalProjectDetail,
|
||||
} from "@/types/projects";
|
||||
import type { TaskCard } from "@/types/api";
|
||||
export function createProjectsApi(request: RequestFn) {
|
||||
const root = "/api/v1/projects/";
|
||||
const write = (url: string, body: unknown, method = "POST") =>
|
||||
request(url, { method, body: JSON.stringify(body) });
|
||||
return {
|
||||
getLocalProjects: (
|
||||
search = "",
|
||||
page = 1,
|
||||
mine = false,
|
||||
): Promise<{
|
||||
count: number;
|
||||
next: string | null;
|
||||
results: LocalProject[];
|
||||
}> => request(root + buildQuery({ search, page, mine })),
|
||||
getLocalProject: (id: number): Promise<LocalProject> =>
|
||||
request(`${root}${id}/`),
|
||||
getProjectOptions: (): Promise<{
|
||||
can_create: boolean;
|
||||
employees: { id: number; name: string }[];
|
||||
manageable_projects: ProjectOption[];
|
||||
}> => request(root + "options/"),
|
||||
getExternalProjectCandidates: (
|
||||
q = "",
|
||||
page = 1,
|
||||
): Promise<{
|
||||
count: number;
|
||||
next: number | null;
|
||||
previous: number | null;
|
||||
results: ExternalProjectOption[];
|
||||
}> => request(root + "external-candidates/" + buildQuery({ q, page })),
|
||||
getExternalProjectDetail: (
|
||||
source: number,
|
||||
externalId: string,
|
||||
): Promise<ExternalProjectDetail> =>
|
||||
request(
|
||||
root +
|
||||
"external-project/" +
|
||||
buildQuery({ source, external_id: externalId }),
|
||||
),
|
||||
createProjectFromExternal: (
|
||||
external: Pick<
|
||||
ExternalProjectOption,
|
||||
"source" | "external_id" | "external_name"
|
||||
>,
|
||||
data: ProjectInput,
|
||||
): Promise<LocalProject> =>
|
||||
write(root + "from-external/", {
|
||||
source: external.source,
|
||||
external_id: external.external_id,
|
||||
...data,
|
||||
}),
|
||||
saveLocalProject: (
|
||||
data: ProjectInput,
|
||||
id?: number,
|
||||
): Promise<LocalProject> =>
|
||||
write(id ? `${root}${id}/` : root, data, id ? "PATCH" : "POST"),
|
||||
getProjectMembers: (id: number): Promise<ProjectMember[]> =>
|
||||
request(`${root}${id}/members/`),
|
||||
saveProjectMember: (
|
||||
id: number,
|
||||
data: Omit<ProjectMember, "id" | "employee_name">,
|
||||
): Promise<ProjectMember[]> => write(`${root}${id}/members/`, data),
|
||||
removeProjectMember: (
|
||||
id: number,
|
||||
member: number,
|
||||
): Promise<ProjectMember[]> =>
|
||||
write(`${root}${id}/members/`, { id: member }, "DELETE"),
|
||||
getProjectStages: (id: number): Promise<ProjectStage[]> =>
|
||||
request(`${root}${id}/stages/`),
|
||||
saveProjectStage: (
|
||||
id: number,
|
||||
data: Partial<ProjectStage>,
|
||||
): Promise<ProjectStage[]> => write(`${root}${id}/stages/`, data),
|
||||
removeProjectStage: (id: number, stage: number): Promise<ProjectStage[]> =>
|
||||
write(`${root}${id}/stages/`, { id: stage }, "DELETE"),
|
||||
getProjectResources: (id: number): Promise<ProjectResource[]> =>
|
||||
request(`${root}${id}/resources/`),
|
||||
saveProjectResource: (
|
||||
id: number,
|
||||
data: Omit<ProjectResource, "id">,
|
||||
): Promise<ProjectResource[]> => write(`${root}${id}/resources/`, data),
|
||||
removeProjectResource: (
|
||||
id: number,
|
||||
resource: number,
|
||||
): Promise<ProjectResource[]> =>
|
||||
write(`${root}${id}/resources/`, { id: resource }, "DELETE"),
|
||||
getProjectTaskLinks: (id: number): Promise<TaskCard[]> =>
|
||||
request(`${root}${id}/task-links/`),
|
||||
linkProjectTask: (id: number, taskId: number): Promise<TaskCard[]> =>
|
||||
write(`${root}${id}/task-links/`, { task_id: taskId }),
|
||||
unlinkProjectTask: (id: number, linkId: number): Promise<TaskCard[]> =>
|
||||
write(`${root}${id}/task-links/`, { id: linkId }, "DELETE"),
|
||||
getProjectBoardLinks: (id: number): Promise<ProjectBoardLink[]> =>
|
||||
request(`${root}${id}/board-links/`),
|
||||
linkProjectBoard: (
|
||||
id: number,
|
||||
boardId: number,
|
||||
): Promise<ProjectBoardLink[]> =>
|
||||
write(`${root}${id}/board-links/`, { board_id: boardId }),
|
||||
unlinkProjectBoard: (
|
||||
id: number,
|
||||
linkId: number,
|
||||
): Promise<ProjectBoardLink[]> =>
|
||||
write(`${root}${id}/board-links/`, { id: linkId }, "DELETE"),
|
||||
getProjectBindings: (id: number): Promise<ProjectBinding[]> =>
|
||||
request(`${root}${id}/bindings/`),
|
||||
saveProjectBinding: (
|
||||
id: number,
|
||||
data: Pick<
|
||||
ProjectBinding,
|
||||
"source" | "external_id" | "external_name" | "object_type"
|
||||
>,
|
||||
): Promise<ProjectBinding[]> => write(`${root}${id}/bindings/`, data),
|
||||
removeProjectBinding: (
|
||||
id: number,
|
||||
binding: number,
|
||||
): Promise<ProjectBinding[]> =>
|
||||
write(`${root}${id}/bindings/`, { id: binding }, "DELETE"),
|
||||
getExternalProjectOptions: (
|
||||
id: number,
|
||||
q = "",
|
||||
): Promise<{
|
||||
sources: { id: number; name: string }[];
|
||||
external_projects: ExternalProjectOption[];
|
||||
}> => request(`${root}${id}/external-options/${buildQuery({ q })}`),
|
||||
getProjectHistory: (id: number): Promise<ProjectEvent[]> =>
|
||||
request(`${root}${id}/history/`),
|
||||
getProjectTimeReport: (
|
||||
id: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<ProjectTimeReport> =>
|
||||
request(`${root}${id}/time-report/${buildQuery({ from, to })}`),
|
||||
getProjectFinances: (
|
||||
id: number,
|
||||
from: string,
|
||||
to: string,
|
||||
currency = "RUB",
|
||||
): Promise<ProjectFinance> =>
|
||||
request(`${root}${id}/finances/${buildQuery({ from, to, currency })}`),
|
||||
getProjectDay: (date: string): Promise<ProjectDay> =>
|
||||
request(root + "my-day/" + buildQuery({ date })),
|
||||
saveProjectDay: (
|
||||
day: Pick<
|
||||
ProjectDay,
|
||||
"date" | "version" | "status" | "note" | "allocations"
|
||||
>,
|
||||
): Promise<ProjectDay> => write(root + "my-day/", day),
|
||||
getProjectCalendar: (): Promise<
|
||||
{ date: string; status: string; hours: string | null }[]
|
||||
> => request(root + "my-calendar/"),
|
||||
claimProjectReminder: (snooze = false): Promise<{ date: string | null }> =>
|
||||
write(root + "reminder/", { snooze }),
|
||||
};
|
||||
}
|
||||
@ -67,6 +67,7 @@ export type ReportingEntryInput = {
|
||||
};
|
||||
export type ReportingEntry = ReportingEntryInput & {
|
||||
project_name: string;
|
||||
external_project_name: string;
|
||||
id: number;
|
||||
product_name: string;
|
||||
employee_name: string;
|
||||
@ -77,7 +78,7 @@ export type AnalyticsOptions = {
|
||||
projects: {
|
||||
id: number;
|
||||
name: string;
|
||||
source_id: number;
|
||||
source_ids: number[];
|
||||
archived: boolean;
|
||||
}[];
|
||||
products: { id: string; name: string }[];
|
||||
@ -90,6 +91,7 @@ export type AnalyticsOptions = {
|
||||
export type ProjectPayment = {
|
||||
id: number;
|
||||
project_name: string;
|
||||
external_project_name: string;
|
||||
source_name: string;
|
||||
document_type: string;
|
||||
number: string;
|
||||
|
||||
155
frontend/src/types/projects.ts
Normal file
155
frontend/src/types/projects.ts
Normal file
@ -0,0 +1,155 @@
|
||||
export type LocalProject = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
customer: string;
|
||||
manager: number;
|
||||
manager_name: string;
|
||||
status: string;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
can_manage: boolean;
|
||||
can_view_finances: boolean;
|
||||
};
|
||||
export type ProjectInput = Pick<
|
||||
LocalProject,
|
||||
| "name"
|
||||
| "description"
|
||||
| "customer"
|
||||
| "manager"
|
||||
| "status"
|
||||
| "starts_on"
|
||||
| "ends_on"
|
||||
>;
|
||||
export type ProjectMember = {
|
||||
id: number;
|
||||
employee: number;
|
||||
employee_name: string;
|
||||
role: string;
|
||||
planned_percent: string;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
};
|
||||
export type ProjectStage = {
|
||||
id: number;
|
||||
name: string;
|
||||
due_on: string | null;
|
||||
done: boolean;
|
||||
position: number;
|
||||
};
|
||||
export type ProjectResource = {
|
||||
id: number;
|
||||
title: string;
|
||||
kind: string;
|
||||
url: string;
|
||||
};
|
||||
export type ProjectBoardLink = {
|
||||
id: number;
|
||||
board_id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
export type ProjectBinding = {
|
||||
id: number;
|
||||
source: number;
|
||||
source_name: string;
|
||||
object_type: string;
|
||||
external_id: string;
|
||||
external_name: string;
|
||||
};
|
||||
export type ExternalProjectOption = {
|
||||
source: number;
|
||||
source_name: string;
|
||||
external_id: string;
|
||||
external_name: string;
|
||||
bound: boolean;
|
||||
};
|
||||
export type ExternalProjectDetail = ExternalProjectOption & {
|
||||
code: string;
|
||||
description: string;
|
||||
synced_at: string | null;
|
||||
source_updated_at: string | null;
|
||||
payment_count: number;
|
||||
payments: { currency: string; count: number; amount: string | number }[];
|
||||
payment_first_at: string | null;
|
||||
payment_last_at: string | null;
|
||||
sale_count: number;
|
||||
sale_first_at: string | null;
|
||||
sale_last_at: string | null;
|
||||
};
|
||||
export type ProjectOption = { id: number; name: string };
|
||||
export type AllocationRow = {
|
||||
project: number | null;
|
||||
project_name?: string;
|
||||
activity: string;
|
||||
percent: string;
|
||||
};
|
||||
export type ProjectDay = {
|
||||
date: string;
|
||||
version: number;
|
||||
status: "draft" | "confirmed";
|
||||
hours: string | number | null;
|
||||
hours_source: string;
|
||||
attendance_changed: boolean;
|
||||
note: string;
|
||||
allocations: AllocationRow[];
|
||||
projects: { id: number; name: string }[];
|
||||
};
|
||||
export type ProjectTimeReport = {
|
||||
from: string;
|
||||
to: string;
|
||||
scope: "team" | "self";
|
||||
confirmed_hours: string | number;
|
||||
unknown_days: number;
|
||||
draft_days: number;
|
||||
rows: {
|
||||
date: string;
|
||||
employee: string;
|
||||
percent: string | number;
|
||||
hours: string | number | null;
|
||||
status: string;
|
||||
hours_source: string;
|
||||
}[];
|
||||
};
|
||||
export type ProjectFinance = {
|
||||
from: string;
|
||||
to: string;
|
||||
currency: string;
|
||||
external_project_count: number;
|
||||
payment_count: number;
|
||||
paid: string | number | null;
|
||||
categories: { expense_category: string; paid: string | number }[];
|
||||
labor_hours: string | number;
|
||||
labor_cost: string | number;
|
||||
exact_labor_cost: string | number;
|
||||
preliminary_labor_cost: string | number;
|
||||
exact_labor_hours: string | number;
|
||||
preliminary_labor_hours: string | number;
|
||||
labor_cost_status: "exact" | "preliminary" | "mixed" | "unavailable";
|
||||
labor_hour_cost: string | number | null;
|
||||
unpriced_labor_hours: string | number;
|
||||
unknown_hours_days: number;
|
||||
labor_cost_complete: boolean;
|
||||
labor: {
|
||||
employee_id: number;
|
||||
employee: string;
|
||||
hours: string | number;
|
||||
cost: string | number;
|
||||
exact_cost: string | number;
|
||||
preliminary_cost: string | number;
|
||||
exact_hours: string | number;
|
||||
preliminary_hours: string | number;
|
||||
cost_status: "exact" | "preliminary" | "mixed" | "unavailable";
|
||||
hour_cost: string | number | null;
|
||||
unpriced_hours: string | number;
|
||||
unknown_days: number;
|
||||
cost_complete: boolean;
|
||||
}[];
|
||||
};
|
||||
export type ProjectEvent = {
|
||||
id: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
details: Record<string, string>;
|
||||
created_at: string;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user