From efd4a84c89dd076a07736d0738596825ecd774be Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 7 Sep 2026 16:59:38 +0300 Subject: [PATCH] Update application functionality and supporting components --- README.md | 2 + backend/api/v1/finance/analytics.py | 83 +- backend/api/v1/projects/__init__.py | 0 backend/api/v1/projects/serializers.py | 185 +++ backend/api/v1/projects/urls.py | 12 + backend/api/v1/projects/views.py | 730 +++++++++ backend/api/v1/urls.py | 1 + backend/commerce/README.md | 11 + backend/eusrr_backend/settings.py | 1 + backend/finance/analytics/labor.py | 311 ++++ backend/finance/analytics/models.py | 5 +- backend/finance/analytics/projects.py | 415 ++++- backend/finance/analytics/sales.py | 34 +- .../0034_reportingentry_local_project.py | 45 + backend/projects/README.md | 119 ++ backend/projects/__init__.py | 0 backend/projects/admin.py | 52 + backend/projects/apps.py | 7 + backend/projects/migrations/0001_initial.py | 478 ++++++ .../migrations/0002_projectboardlink.py | 31 + backend/projects/migrations/__init__.py | 0 backend/projects/models/__init__.py | 24 + backend/projects/models/external_binding.py | 20 + backend/projects/models/membership.py | 34 + backend/projects/models/project.py | 101 ++ backend/projects/models/stage.py | 14 + backend/projects/models/time_allocation.py | 88 ++ backend/projects/permissions.py | 17 + backend/projects/services/__init__.py | 0 backend/projects/services/time_tracking.py | 195 +++ backend/tasks/access.py | 9 + ...alter_taskactivity_object_kind_and_more.py | 23 + backend/tasks/models.py | 2 + backend/tests/finance/test_projects.py | 314 +++- backend/tests/projects/test_projects.py | 558 +++++++ docs/README.md | 2 + docs/guides/COMPANY_ANALYTICS.md | 51 +- frontend/src/app/profile/page.tsx | 2 + .../src/app/projects/[projectId]/page.tsx | 1376 +++++++++++++++++ frontend/src/app/projects/page.tsx | 904 +++++++++++ frontend/src/components/AppShell.tsx | 6 +- .../analytics/AnalyticsHelpContent.tsx | 9 +- .../analytics/AnalyticsWorkspace.tsx | 9 +- .../components/analytics/ProjectsReport.tsx | 102 +- .../components/analytics/ReportingJournal.tsx | 6 +- .../finance/PayrollDailyWorkSection.tsx | 5 + .../projects/DailyTimeAllocationModal.tsx | 353 +++++ .../projects/DailyTimeAllocationPrompt.tsx | 67 + .../src/components/projects/ProjectEditor.tsx | 172 +++ .../ProjectParticipationProfileSection.tsx | 82 + frontend/src/components/projects/shared.tsx | 50 + .../src/components/tasks/RelatedTaskLinks.tsx | 77 +- frontend/src/lib/api/index.ts | 2 + frontend/src/lib/api/projects.ts | 174 +++ frontend/src/types/analytics.ts | 4 +- frontend/src/types/projects.ts | 155 ++ 56 files changed, 7353 insertions(+), 176 deletions(-) create mode 100644 backend/api/v1/projects/__init__.py create mode 100644 backend/api/v1/projects/serializers.py create mode 100644 backend/api/v1/projects/urls.py create mode 100644 backend/api/v1/projects/views.py create mode 100644 backend/finance/analytics/labor.py create mode 100644 backend/finance/migrations/0034_reportingentry_local_project.py create mode 100644 backend/projects/README.md create mode 100644 backend/projects/__init__.py create mode 100644 backend/projects/admin.py create mode 100644 backend/projects/apps.py create mode 100644 backend/projects/migrations/0001_initial.py create mode 100644 backend/projects/migrations/0002_projectboardlink.py create mode 100644 backend/projects/migrations/__init__.py create mode 100644 backend/projects/models/__init__.py create mode 100644 backend/projects/models/external_binding.py create mode 100644 backend/projects/models/membership.py create mode 100644 backend/projects/models/project.py create mode 100644 backend/projects/models/stage.py create mode 100644 backend/projects/models/time_allocation.py create mode 100644 backend/projects/permissions.py create mode 100644 backend/projects/services/__init__.py create mode 100644 backend/projects/services/time_tracking.py create mode 100644 backend/tasks/migrations/0042_alter_taskactivity_object_kind_and_more.py create mode 100644 backend/tests/projects/test_projects.py create mode 100644 frontend/src/app/projects/[projectId]/page.tsx create mode 100644 frontend/src/app/projects/page.tsx create mode 100644 frontend/src/components/projects/DailyTimeAllocationModal.tsx create mode 100644 frontend/src/components/projects/DailyTimeAllocationPrompt.tsx create mode 100644 frontend/src/components/projects/ProjectEditor.tsx create mode 100644 frontend/src/components/projects/ProjectParticipationProfileSection.tsx create mode 100644 frontend/src/components/projects/shared.tsx create mode 100644 frontend/src/lib/api/projects.ts create mode 100644 frontend/src/types/projects.ts diff --git a/README.md b/README.md index 5e8e934a..f59144ea 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/api/v1/finance/analytics.py b/backend/api/v1/finance/analytics.py index f1e1e896..66521885 100644 --- a/backend/api/v1/finance/analytics.py +++ b/backend/api/v1/finance/analytics.py @@ -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])) diff --git a/backend/api/v1/projects/__init__.py b/backend/api/v1/projects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/projects/serializers.py b/backend/api/v1/projects/serializers.py new file mode 100644 index 00000000..e0194342 --- /dev/null +++ b/backend/api/v1/projects/serializers.py @@ -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 diff --git a/backend/api/v1/projects/urls.py b/backend/api/v1/projects/urls.py new file mode 100644 index 00000000..d9cb7b6a --- /dev/null +++ b/backend/api/v1/projects/urls.py @@ -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, +] diff --git a/backend/api/v1/projects/views.py b/backend/api/v1/projects/views.py new file mode 100644 index 00000000..b37860a5 --- /dev/null +++ b/backend/api/v1/projects/views.py @@ -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}) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index cbceeddc..5553adfb 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -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 diff --git a/backend/commerce/README.md b/backend/commerce/README.md index 1904100b..aa39e1f8 100644 --- a/backend/commerce/README.md +++ b/backend/commerce/README.md @@ -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. diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 4ddb998e..0acd4926 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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 diff --git a/backend/finance/analytics/labor.py b/backend/finance/analytics/labor.py new file mode 100644 index 00000000..56674bf3 --- /dev/null +++ b/backend/finance/analytics/labor.py @@ -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] + ) + ], + } diff --git a/backend/finance/analytics/models.py b/backend/finance/analytics/models.py index 559254a4..36485d85 100644 --- a/backend/finance/analytics/models.py +++ b/backend/finance/analytics/models.py @@ -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 ) diff --git a/backend/finance/analytics/projects.py b/backend/finance/analytics/projects.py index 183bb380..ebfe63a6 100644 --- a/backend/finance/analytics/projects.py +++ b/backend/finance/analytics/projects.py @@ -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()) ], } diff --git a/backend/finance/analytics/sales.py b/backend/finance/analytics/sales.py index 14139f44..26f052b3 100644 --- a/backend/finance/analytics/sales.py +++ b/backend/finance/analytics/sales.py @@ -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) diff --git a/backend/finance/migrations/0034_reportingentry_local_project.py b/backend/finance/migrations/0034_reportingentry_local_project.py new file mode 100644 index 00000000..52b61f2d --- /dev/null +++ b/backend/finance/migrations/0034_reportingentry_local_project.py @@ -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), + ] diff --git a/backend/projects/README.md b/backend/projects/README.md new file mode 100644 index 00000000..79576b96 --- /dev/null +++ b/backend/projects/README.md @@ -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 для новых компонентов. diff --git a/backend/projects/__init__.py b/backend/projects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/projects/admin.py b/backend/projects/admin.py new file mode 100644 index 00000000..cafbbbd3 --- /dev/null +++ b/backend/projects/admin.py @@ -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) diff --git a/backend/projects/apps.py b/backend/projects/apps.py new file mode 100644 index 00000000..7a65e869 --- /dev/null +++ b/backend/projects/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "projects" + verbose_name = "Проекты" diff --git a/backend/projects/migrations/0001_initial.py b/backend/projects/migrations/0001_initial.py new file mode 100644 index 00000000..13f3f0ef --- /dev/null +++ b/backend/projects/migrations/0001_initial.py @@ -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", + ), + ), + ] diff --git a/backend/projects/migrations/0002_projectboardlink.py b/backend/projects/migrations/0002_projectboardlink.py new file mode 100644 index 00000000..0b5cf612 --- /dev/null +++ b/backend/projects/migrations/0002_projectboardlink.py @@ -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')], + }, + ), + ] diff --git a/backend/projects/migrations/__init__.py b/backend/projects/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/projects/models/__init__.py b/backend/projects/models/__init__.py new file mode 100644 index 00000000..8eee571d --- /dev/null +++ b/backend/projects/models/__init__.py @@ -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", +] diff --git a/backend/projects/models/external_binding.py b/backend/projects/models/external_binding.py new file mode 100644 index 00000000..ad1634cb --- /dev/null +++ b/backend/projects/models/external_binding.py @@ -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", + ) + ] diff --git a/backend/projects/models/membership.py b/backend/projects/models/membership.py new file mode 100644 index 00000000..77e7b062 --- /dev/null +++ b/backend/projects/models/membership.py @@ -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", + ), + ] diff --git a/backend/projects/models/project.py b/backend/projects/models/project.py new file mode 100644 index 00000000..1cc920b2 --- /dev/null +++ b/backend/projects/models/project.py @@ -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" + ) + ] diff --git a/backend/projects/models/stage.py b/backend/projects/models/stage.py new file mode 100644 index 00000000..8a9397f8 --- /dev/null +++ b/backend/projects/models/stage.py @@ -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"] diff --git a/backend/projects/models/time_allocation.py b/backend/projects/models/time_allocation.py new file mode 100644 index 00000000..a997e5c7 --- /dev/null +++ b/backend/projects/models/time_allocation.py @@ -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) diff --git a/backend/projects/permissions.py b/backend/projects/permissions.py new file mode 100644 index 00000000..1b067927 --- /dev/null +++ b/backend/projects/permissions.py @@ -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() diff --git a/backend/projects/services/__init__.py b/backend/projects/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/projects/services/time_tracking.py b/backend/projects/services/time_tracking.py new file mode 100644 index 00000000..10dbe96d --- /dev/null +++ b/backend/projects/services/time_tracking.py @@ -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 diff --git a/backend/tasks/access.py b/backend/tasks/access.py index d2ad049d..6f45e292 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -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 diff --git a/backend/tasks/migrations/0042_alter_taskactivity_object_kind_and_more.py b/backend/tasks/migrations/0042_alter_taskactivity_object_kind_and_more.py new file mode 100644 index 00000000..ba6df5c1 --- /dev/null +++ b/backend/tasks/migrations/0042_alter_taskactivity_object_kind_and_more.py @@ -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='Тип связи'), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 6beb6a3e..9363eb67 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -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", "Календарное событие" diff --git a/backend/tests/finance/test_projects.py b/backend/tests/finance/test_projects.py index b7f10100..f4669dbc 100644 --- a/backend/tests/finance/test_projects.py +++ b/backend/tests/finance/test_projects.py @@ -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, diff --git a/backend/tests/projects/test_projects.py b/backend/tests/projects/test_projects.py new file mode 100644 index 00000000..06a185ef --- /dev/null +++ b/backend/tests/projects/test_projects.py @@ -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, + } diff --git a/docs/README.md b/docs/README.md index abad1e9b..ae65a257 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,3 +37,5 @@ Troubleshooting должен находиться в соответствующ session report. При добавлении документа сначала проверьте, нельзя ли обновить существующий guide или README модуля. Отдельные `*_PLAN.md`, `*_PROGRESS.md`, `*_COMPLETE.md` и session reports в репозитории не создаются. + +Локальные проекты, внешние привязки и ежедневное участие: [Projects](../backend/projects/README.md). diff --git a/docs/guides/COMPANY_ANALYTICS.md b/docs/guides/COMPANY_ANALYTICS.md index 147a7eb9..a8149980 100644 --- a/docs/guides/COMPANY_ANALYTICS.md +++ b/docs/guides/COMPANY_ANALYTICS.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 diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index bc05dc19..519c0699 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -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() { + = { + overview: "Обзор", + team: "Команда", + time: "Участие и время", + resources: "Задачи и доски", + finances: "Финансы", + bindings: "Связи с сервисами", + history: "История", +}; +const laborStatusLabel = (status: ProjectFinance["labor_cost_status"]) => + ({ + exact: "Точная — период закрыт", + preliminary: "Предварительная — период открыт", + mixed: "Смешанная — есть открытые периоды", + unavailable: "Нет оценки", + })[status]; +export default function ProjectPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const { projectId } = use(params); + const id = Number(projectId); + const [project, setProject] = useState(null), + [tab, setTab] = useState("overview"), + [error, setError] = useState(""), + [busy, setBusy] = useState(false), + [wide, setWide] = useState(false), + [edit, setEdit] = useState(false), + [day, setDay] = useState(false), + [help, setHelp] = useState(false); + const [members, setMembers] = useState([]), + [stages, setStages] = useState([]), + [resources, setResources] = useState([]), + [boardLinks, setBoardLinks] = useState([]), + [bindings, setBindings] = useState([]), + [history, setHistory] = useState([]); + const [people, setPeople] = useState<{ id: number; name: string }[]>([]), + [sources, setSources] = useState<{ id: number; name: string }[]>([]), + [external, setExternal] = useState([]), + [query, setQuery] = useState(""); + const [from, setFrom] = useState(today().slice(0, 8) + "01"), + [to, setTo] = useState(today()), + [currency, setCurrency] = useState("RUB"), + [time, setTime] = useState(null), + [finance, setFinance] = useState(null); + const [member, setMember] = useState<{ + id?: number; + employee: number; + role: string; + planned_percent: string; + starts_on: string | null; + ends_on: string | null; + } | null>(null), + [stage, setStage] = useState | null>(null), + [boardLinkOpen, setBoardLinkOpen] = useState(false), + [boardOptions, setBoardOptions] = useState([]), + [boardSearch, setBoardSearch] = useState(""), + [selectedBoardId, setSelectedBoardId] = useState(""), + [boardLoading, setBoardLoading] = useState(false), + [binding, setBinding] = useState | null>(null); + const mutate = async (fn: () => Promise) => { + setError(""); + setBusy(true); + try { + await fn(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + }; + const load = useCallback(async () => { + const [p, m, s, r, o, boardLinkRows] = await Promise.all([ + apiClient.getLocalProject(id), + apiClient.getProjectMembers(id), + apiClient.getProjectStages(id), + apiClient.getProjectResources(id), + apiClient.getProjectOptions(), + apiClient.getProjectBoardLinks(id), + ]); + setProject(p); + setMembers(m); + setStages(s); + setResources(r); + setBoardLinks(boardLinkRows); + setPeople(o.employees); + }, [id]); + const loadProjectTaskLinks = useCallback( + () => apiClient.getProjectTaskLinks(id), + [id], + ); + const linkProjectTask = useCallback( + async (taskId: number) => { + await apiClient.linkProjectTask(id, taskId); + }, + [id], + ); + const openBoardLinkModal = useCallback(async () => { + setBoardLinkOpen(true); + setBoardLoading(true); + setError(""); + try { + const response = await apiClient.getTaskBoards(); + const rows = (response.results || response || []) as TaskBoard[]; + const linked = new Set(boardLinks.map((item) => item.board_id)); + const available = rows.filter( + (item) => !item.is_archived && !linked.has(item.id), + ); + setBoardOptions(available); + setSelectedBoardId(available[0]?.id || ""); + setBoardSearch(""); + } catch (e) { + setError(errorText(e)); + } finally { + setBoardLoading(false); + } + }, [boardLinks]); + useEffect(() => { + const timer = setTimeout( + () => void load().catch((e) => setError(errorText(e))), + 0, + ); + return () => clearTimeout(timer); + }, [load]); + useEffect(() => { + let live = true; + const timer = setTimeout(() => { + setError(""); + if (tab === "time") { + setTime(null); + apiClient + .getProjectTimeReport(id, from, to) + .then((r) => { + if (live) setTime(r); + }) + .catch((e) => { + if (live) setError(errorText(e)); + }); + } + if (tab === "finances") { + setFinance(null); + apiClient + .getProjectFinances(id, from, to, currency) + .then((r) => { + if (live) setFinance(r); + }) + .catch((e) => { + if (live) setError(errorText(e)); + }); + } + if (tab === "history") + apiClient + .getProjectHistory(id) + .then((r) => { + if (live) setHistory(r); + }) + .catch((e) => { + if (live) setError(errorText(e)); + }); + if (tab === "bindings") + apiClient + .getProjectBindings(id) + .then((r) => { + if (live) setBindings(r); + }) + .catch((e) => { + if (live) setError(errorText(e)); + }); + }, 0); + return () => { + live = false; + clearTimeout(timer); + }; + }, [id, tab, from, to, currency]); + useEffect(() => { + if (tab !== "bindings") return; + let live = true; + const timer = setTimeout(() => { + apiClient + .getExternalProjectOptions(id, query) + .then((r) => { + if (live) { + setExternal(r.external_projects); + setSources(r.sources); + } + }) + .catch((e) => { + if (live) setError(errorText(e)); + }); + }, 200); + return () => { + live = false; + clearTimeout(timer); + }; + }, [id, tab, query, bindings]); + const modalError = error ? ( +

+ {error} +

+ ) : null; + return ( + +
+ + + Все проекты + + {project ? ( + <> + +
+
+

+ Локальный проект +

+

+ {project.name} +

+

+ {projectStatuses[project.status]} · {project.manager_name} +

+
+
+ + {project.can_manage && ( + + )} + + +
+
+ + {(Object.entries(tabs) as [Tab, string][]) + .filter(([key]) => { + if (key === "finances") return project.can_view_finances; + return ( + project.can_manage || + !["bindings", "history"].includes(key) + ); + }) + .map(([key, label]) => ( + + ))} + +
+ {modalError} + {tab === "overview" && ( +
+ +

О проекте

+

+ {project.description || "Описание пока не заполнено."} +

+
+
+
Заказчик
+
{project.customer || "Не указан"}
+
+
+
Сроки
+
+ {project.starts_on || "Не задано"} —{" "} + {project.ends_on || "Не задано"} +
+
+
+
Команда
+
{members.length} участников
+
+
+
+ +
+

Этапы

+ {project.can_manage && ( + + )} +
+ {stages.map((s) => ( +
+ + void mutate(async () => + setStages( + await apiClient.saveProjectStage(id, { + ...s, + done: e.target.checked, + }), + ), + ) + } + /> +
+

+ {s.name} +

+

+ {s.due_on || "Без срока"} +

+
+ {project.can_manage && ( + <> + + + + )} +
+ ))} + {!stages.length && ( +

+ Добавьте этапы, чтобы отслеживать результат работы. +

+ )} +
+
+ )} + {tab === "team" && ( + +
+

Команда проекта

+ {project.can_manage && ( + + )} +
+
+ {members.map((m) => ( +
+

{m.employee_name}

+

{m.role}

+

+ Плановая загрузка: {number(m.planned_percent)}% +

+

+ {m.starts_on || "Без ограничения"} —{" "} + {m.ends_on || "Без окончания"} +

+ {project.can_manage && ( + + )} +
+ ))} +
+

+ Для завершения участия укажите дату окончания. Плановая + загрузка не заменяет фактическое распределение дня. +

+
+ )} + {(tab === "time" || tab === "finances") && ( + +
+ + { + if (e.target.value) setFrom(e.target.value); + }} + /> + + + { + if (e.target.value) setTo(e.target.value); + }} + /> + + {tab === "finances" && ( + + + + )} +
+
+ )} + {tab === "time" && time && ( + <> +
+ +

+ Подтверждено часов участия +

+ + {number(time.confirmed_hours)} + +
+ +

+ Подтверждённых дней без часов +

+ {time.unknown_days} +
+ +

Черновиков

+ {time.draft_days} +
+
+

+ {time.scope === "team" ? "Участие команды" : "Ваше участие"}. + Часы — доля продолжительности дня по посещаемости. Неизвестные + часы не включаются в итог. +

+ + ({ + id: String(i), + name: r.employee, + date: r.date, + percent: r.percent, + hours: r.hours, + status: + r.status === "confirmed" ? "Подтверждено" : "Черновик", + }))} + columns={[ + { key: "name", label: "Сотрудник", text: true }, + { key: "date", label: "Дата", text: true }, + { key: "percent", label: "Доля дня, %" }, + { key: "hours", label: "Часы участия" }, + { key: "status", label: "Состояние", text: true }, + ]} + /> + + + )} + {tab === "finances" && finance && ( + <> +
+ +

+ Оплачено по источникам +

+ + {number(finance.paid)} {finance.currency} + +

+ {finance.payment_count} документов ·{" "} + {finance.external_project_count} внешних проектов +

+
+ +

Стоимость труда

+ + {number(finance.labor_cost)} {finance.currency} + +

+ {laborStatusLabel(finance.labor_cost_status)} +

+

+ {finance.labor_hour_cost === null + ? "Нет полной оценки стоимости часа" + : `В среднем ${number(finance.labor_hour_cost)} ${finance.currency} / ч`} +

+
+ +

+ Подтверждённое время +

+ + {number(finance.labor_hours)} ч + +

+ {finance.unknown_hours_days} дней без достоверных часов +

+
+
+ {!finance.labor_cost_complete && ( +

+ Денежная оценка неполная:{" "} + {number(finance.unpriced_labor_hours)} ч без предварительной + базы или точного расчёта закрытого периода; дней без + достоверных часов — {finance.unknown_hours_days}. +

+ )} +

+ До закрытия зарплатного периода сумма предварительная: по + текущему расчёту или утверждённой ставке. После закрытия + используется итоговое начисление сотруднику, включая премии, + доплаты и корректировки. Выплаты источников включают движения + денег, поэтому не складываются со стоимостью труда как + итоговые расходы. +

+ + ({ + id: String(row.employee_id), + name: row.employee, + hours: row.hours, + cost: row.cost, + cost_status: laborStatusLabel(row.cost_status), + hour_cost: row.hour_cost, + unpriced_hours: row.unpriced_hours, + unknown_days: row.unknown_days, + }))} + columns={[ + { key: "name", label: "Сотрудник", text: true }, + { + key: "hours", + label: "Часы", + digits: 2, + additive: true, + }, + { + key: "cost", + label: `Стоимость, ${finance.currency}`, + digits: 2, + additive: true, + }, + { key: "cost_status", label: "Статус суммы", text: true }, + { + key: "hour_cost", + label: `Стоимость часа, ${finance.currency}`, + digits: 2, + }, + { + key: "unpriced_hours", + label: "Часы без оценки", + digits: 2, + additive: true, + }, + { + key: "unknown_days", + label: "Дней без часов", + digits: 0, + additive: true, + }, + ]} + /> + ({ + id: String(i), + name: c.expense_category || "Без статьи", + paid: c.paid, + }))} + columns={[ + { key: "name", label: "Статья", text: true }, + { + key: "paid", + label: `Выплачено, ${finance.currency}`, + digits: 2, + additive: true, + }, + ]} + /> + + + )} + {tab === "resources" && ( + +
+

Рабочие связи проекта

+

+ Выбирайте существующие задачи и доски из доступных объектов + портала. Название и адрес формируются автоматически. +

+
+
+
+ + apiClient.unlinkProjectTask(id, linkId) + } + canLink={project.can_manage} + /> +
+
+
+
+ +

+ Связанные доски ({boardLinks.length}) +

+
+ {project.can_manage && ( + + )} +
+ {boardLinks.length ? ( +
+ {boardLinks.map((link) => ( +
+ + + {link.name} + + {project.can_manage && ( + + )} +
+ ))} +
+ ) : ( +
+

+ Связанных досок нет +

+
+ )} +
+
+ {resources.length > 0 && ( +
+

+ Ранее добавленные ссылки +

+
+ {resources.map((resource) => ( +
+ + + {resource.title} + + {project.can_manage && ( + + )} +
+ ))} +
+
+ )} +
+ )} + {tab === "bindings" && ( + <> + +
+

+ Привязанные внешние объекты +

+ +
+ {bindings.map((b) => ( +
+
+

+ {b.external_name || b.external_id} +

+

+ {b.source_name} · {b.object_type} +

+
+ +
+ ))} + {!bindings.length && ( +

+ Проект работает локально. Связи с сервисами необязательны. +

+ )} +
+ +

+ Проекты из подключённых сервисов +

+ setQuery(e.target.value)} + placeholder="Найти проект МойСклад" + /> +

+ До 200 результатов. Привязка добавляет данные в этот + локальный проект и не изменяет источник. +

+
+ {external.map((e) => ( +
+
+

+ {e.external_name} +

+

+ {e.source_name} +

+
+ +
+ ))} +
+
+ + )} + {tab === "history" && ( + +

+ Последние 100 изменений проекта +

+ {history.map((e) => ( +
+

{e.action}

+

+ {e.actor} ·{" "} + {new Date(e.created_at).toLocaleString("ru-RU")} +

+
+ ))} +

+ Версии распределения времени хранятся отдельно от настроек + проекта. +

+
+ )} + + ) : ( + !error &&

Загрузка проекта…

+ )} + {!project && modalError} + {edit && project && ( + setEdit(false)} + onSaved={(p) => { + setProject(p); + setEdit(false); + void load(); + }} + /> + )} + {day && ( + setDay(false)} + onSaved={() => { + if (tab === "time") + void apiClient.getProjectTimeReport(id, from, to).then(setTime); + }} + /> + )} + setHelp(false)} + title="Как работает проект" + size="lg" + > +
+

+ Это самостоятельный локальный проект. Команда, этапы и + распределение времени сохраняются независимо от МойСклад и других + сервисов. +

+

+ Руководитель управляет участием и привязками. Укажите даты + участия, чтобы сотрудник видел подходящие проекты в своём рабочем + дне. +

+

+ Исходящие платежи показывают движение денег по привязанным + проектам. Они не равны расходам или прибыли. Финансовые права + проверяются отдельно. +

+

+ Для завершения проекта измените статус. История участия + сохраняется; записи времени не удаляются при завершении проекта + или участия сотрудника. +

+
+
+ { + if (!busy) setMember(null); + }} + title="Участие в проекте" + size="lg" + > + {member && ( +
{ + e.preventDefault(); + void mutate(async () => { + setMembers(await apiClient.saveProjectMember(id, member)); + setMember(null); + }); + }} + > + + + + + + setMember({ ...member, role: e.target.value }) + } + /> + + + + setMember({ ...member, planned_percent: e.target.value }) + } + /> + +
+ + + setMember({ + ...member, + starts_on: e.target.value || null, + }) + } + /> + + + + setMember({ ...member, ends_on: e.target.value || null }) + } + /> + +
+ {modalError} + +
+ )} +
+ { + if (!busy) setStage(null); + }} + title="Этап проекта" + > + {stage && ( +
{ + e.preventDefault(); + void mutate(async () => { + setStages(await apiClient.saveProjectStage(id, stage)); + setStage(null); + }); + }} + > + + setStage({ ...stage, name: e.target.value })} + /> + + + + setStage({ ...stage, due_on: e.target.value || null }) + } + /> + + {modalError} + +
+ )} +
+ { + if (!boardLoading && !busy) setBoardLinkOpen(false); + }} + title="Связать с доской" + size="md" + footer={ +
+ + +
+ } + > +
+
+

Проект

+

{project?.name}

+
+ + + {!boardLoading && boardOptions.length === 0 && ( +

+ Все доступные доски уже связаны с проектом. +

+ )} + {modalError} +
+
+ { + if (!busy) setBinding(null); + }} + title="Привязка внешнего объекта" + > + {binding && ( +
{ + e.preventDefault(); + void mutate(async () => { + setBindings(await apiClient.saveProjectBinding(id, binding)); + setBinding(null); + }); + }} + > + + + + + + setBinding({ ...binding, object_type: e.target.value }) + } + /> + + + + setBinding({ ...binding, external_id: e.target.value }) + } + /> + + + + setBinding({ ...binding, external_name: e.target.value }) + } + /> + + {modalError} + +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx new file mode 100644 index 00000000..599a986f --- /dev/null +++ b/frontend/src/app/projects/page.tsx @@ -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([]), + [canCreate, setCanCreate] = useState(false), + [create, setCreate] = useState(false), + [date, setDate] = useState(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(null), + [linkExternal, setLinkExternal] = useState( + null, + ), + [linkProjectId, setLinkProjectId] = useState(0), + [externalBusy, setExternalBusy] = useState(false), + [manageableProjects, setManageableProjects] = useState([]); + const [previewExternal, setPreviewExternal] = + useState(null), + [externalDetail, setExternalDetail] = + useState(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 = { + planned: "app-badge", + active: "app-feedback-success", + paused: "app-feedback-warning", + completed: "app-badge-accent", + archived: "app-badge", + }; + return ( + +
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+

+ Проекты +

+ + {loading ? "…" : count} + +
+
+ + {canCreate && ( + setCreate(true)} + icon={} + label="Создать проект" + /> + )} + + +
+
+
+ + { + setSearch(event.target.value); + setPage(1); + }} + /> +
+
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ )) + ) : projects.length ? ( + projects.map((project) => { + const startsOn = formatDate(project.starts_on); + const endsOn = formatDate(project.ends_on); + const dates = + startsOn || endsOn + ? `${startsOn || "Начало не задано"} — ${endsOn || "Срок не задан"}` + : "Сроки не заданы"; + return ( + + + + + + + + {project.name} + + + {projectStatuses[project.status] || project.status} + + + + + + + {project.manager_name} + + + {project.customer ? ( + + + {project.customer} + + ) : null} + + + {dates} + + + + + + ); + }) + ) : ( +
+ +

+ {search + ? "Проекты не найдены" + : "Пока нет доступных проектов. Создайте локальный проект или дождитесь приглашения в команду."} +

+
+ )} +
+ + {!loading && (page > 1 || next) ? ( +
+ Страница {page} +
+ + +
+
+ ) : null} +
+ +

+ +

+
+

+ Выберите, какие записи являются рабочими проектами компании. +

+
+ + { + setExternalSearch(event.target.value); + setExternalPage(1); + }} + /> +
+

+ Учётные категории вроде перемещений и кредитов не создаются как + локальные проекты автоматически. +

+ {externalLoading ? ( +

Загрузка из МойСклад…

+ ) : ( + <> +
+ {externalProjects.map((external) => ( +
+ + {canCreate && ( +
+ + +
+ )} +
+ ))} +
+ {!externalProjects.length && ( +

+ {externalSearch + ? "Непривязанные проекты не найдены." + : "Все активные внешние проекты сопоставлены."} +

+ )} +
+ + Страница {externalPage} + +
+ + +
+
+ + )} +
+
+ +

+ +

+
+ {calendar.map((d) => ( + + ))} + {!calendar.length && ( +

+ Распределите первый день через кнопку «Мой день». +

+ )} +
+
+ {create && ( + setCreate(false)} + onSaved={(p) => router.push(`/projects/${p.id}`)} + /> + )} + {importExternal && ( + + apiClient.createProjectFromExternal(importExternal, data) + } + onClose={() => setImportExternal(null)} + onSaved={(project) => router.push(`/projects/${project.id}`)} + /> + )} + setPreviewExternal(null)} + title="Проект из МойСклад" + size="md" + > + {externalDetailLoading ? ( +
+ +

Загрузка проекта…

+
+ ) : externalDetailError ? ( +
+ {externalDetailError} +
+ ) : externalDetail ? ( +
+
+ + + +
+

+ {externalDetail.external_name} +

+

+ {externalDetail.source_name} + {externalDetail.code + ? ` · Код ${externalDetail.code}` + : " · Код не задан"} +

+
+ + Не привязан + +
+ +
+
+
+ + Расходные документы +
+

+ {externalDetail.payment_count} +

+
+
+
+ + Продажи и возвраты +
+

+ {externalDetail.sale_count} +

+
+
+ + {externalDetail.payments.length ? ( +
+

+ Оплаченные расходы +

+
+ {externalDetail.payments.map((payment) => ( +
+ + {payment.count} док. + + + {formatAmount(payment.amount, payment.currency)} + +
+ ))} +
+
+ ) : null} + +
+

+ Описание +

+

+ {externalDetail.description || + "Описание в МойСклад не заполнено."} +

+
+ +
+
+
Изменено в источнике
+
+ {formatDateTime(externalDetail.source_updated_at)} +
+
+
+
Синхронизировано
+
+ {formatDateTime(externalDetail.synced_at)} +
+
+
+
Внешний ID
+
+ {externalDetail.external_id} +
+
+
+ + {canCreate ? ( +
+ + +
+ ) : null} +
+ ) : null} +
+ { + if (!externalBusy) setLinkExternal(null); + }} + title="Привязать к локальному проекту" + > + {linkExternal && ( +
{ + 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); + } + }} + > +

+ {linkExternal.external_name} + + {" "} + · {linkExternal.source_name} + +

+ +

+ Платежи и другие данные внешнего проекта будут учитываться в + выбранном локальном проекте. Названия не объединяются. +

+ +
+ )} +
+ {date && ( + setDate(null)} + onSaved={() => void refreshCalendar()} + /> + )} + setHelp(false)} + title="Проекты и участие" + size="lg" + > +
+

+ Создавайте проекты внутри портала, добавляйте команду и этапы. + Непривязанные проекты МойСклад показаны в очереди на главной + странице: из записи можно создать локальный проект или привязать + её к существующему. Руководитель также может связать объекты + других подключённых сервисов. Совпадение названий не объединяет + записи автоматически. +

+

+ В «Мой день» распределите время между проектами и общей + деятельностью. Для подтверждения нужно 100%. Часы берутся из + успешного анализа посещаемости; без него часы неизвестны. Проценты + не изменяют зарплату или баллы выработки. +

+

+ Напоминание появляется один раз в день при входе после + отработанной смены, если за последние 14 дней есть незаполненный + день и участие в проекте. Оно не перекрывает другое модальное + окно. Можно отложить на два часа. +

+

+ Участник видит свои часы, руководитель — часы команды. Финансовые + суммы доступны только с правами финансовой аналитики. Завершённые + проекты сохраняют историю. +

+
+
+
+
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 4851dee9..656f120b 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 ? : null} + {!standaloneAppMode && } ); } diff --git a/frontend/src/components/analytics/AnalyticsHelpContent.tsx b/frontend/src/components/analytics/AnalyticsHelpContent.tsx index d453cddd..1c19d2b2 100644 --- a/frontend/src/components/analytics/AnalyticsHelpContent.tsx +++ b/frontend/src/components/analytics/AnalyticsHelpContent.tsx @@ -76,12 +76,15 @@ export function AnalyticsHelpContent({ tab }: { tab: AnalyticsTab }) { } {tab === "projects" && <>

Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж.

-

Проект платежа берётся из поля «Проект» в МойСклад. Платежи без этой - связи остаются в группе «Без проекта». Изменяйте импортированные +

Стоимость труда = подтверждённые часы участия × стоимость часа. Пока зарплатный период открыт, сумма предварительная: используется текущий расчёт, а до его появления — утверждённая ставка и норма часов. После закрытия периода сумма становится точной и рассчитывается из итогового начисления сотруднику с премиями, доплатами и корректировками. Если расчётного периода, нормы или итоговых данных нет, соответствующая часть помечается как неоценённая и не считается нулём.

+

Проектом отчёта служит локальная карточка портала. Платежи, продажи и + расходы подключённых сервисов объединяются по точным внешним привязкам. + Записи без локальной связи остаются в группе «Без привязки». Изменяйте импортированные документы в МойСклад. Синхронизацией управляет администратор в админке, в разделе «Подключения интеграций». В журнал добавляйте только отсутствующие в МойСклад факты. - Архивные проекты сохраняются в отчётах. Отсутствие загруженных + Подтверждённое распределение рабочего времени показывается в часах и + денежной стоимости. Архивные проекты сохраняются в отчётах. Отсутствие загруженных документов не подтверждает отсутствие реальных расходов.

} diff --git a/frontend/src/components/analytics/AnalyticsWorkspace.tsx b/frontend/src/components/analytics/AnalyticsWorkspace.tsx index ffe4ff75..1e6ad775 100644 --- a/frontend/src/components/analytics/AnalyticsWorkspace.tsx +++ b/frontend/src/components/analytics/AnalyticsWorkspace.tsx @@ -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 } > - + {options?.projects .filter( (p) => - !draft.source || String(p.source_id) === draft.source, + !draft.source || + p.source_ids.includes(Number(draft.source)), ) .map((p) => (