From cd64ee4ef73a9904ff30ed18be43b39631ad6789 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 7 Sep 2026 12:23:15 +0300 Subject: [PATCH] Add project analytics and improve commerce integrations --- .gitignore | 3 + backend/api/v1/commerce/serializers.py | 35 +- backend/api/v1/finance/analytics.py | 332 ++++++++++++ backend/api/v1/finance/urls.py | 24 + backend/api/v1/search/views.py | 6 +- backend/commerce/admin.py | 149 ++++- backend/commerce/forms.py | 72 +++ backend/commerce/integrations/moysklad.py | 87 ++- .../commands/sync_moysklad_projects.py | 43 ++ ...customer_external_id_sale_customer_name.py | 24 + .../migrations/0013_restore_sale_customers.py | 36 ++ ...externalproject_projectpayment_and_more.py | 105 ++++ backend/commerce/models/__init__.py | 3 + backend/commerce/models/costs.py | 7 +- backend/commerce/models/projects.py | 60 +++ backend/commerce/models/sales.py | 7 + backend/commerce/models/source.py | 2 + .../commerce/services/moysklad_projects.py | 141 +++++ backend/commerce/services/moysklad_sync.py | 27 + backend/finance/analytics/__init__.py | 1 + backend/finance/analytics/models.py | 103 ++++ backend/finance/analytics/people.py | 235 ++++++++ backend/finance/analytics/projects.py | 114 ++++ backend/finance/analytics/sales.py | 261 +++++++++ .../finance/migrations/0032_reportingentry.py | 174 ++++++ .../migrations/0033_reportingentry_project.py | 20 + backend/finance/models.py | 1 + backend/tests/api/v1/test_search.py | 17 +- backend/tests/commerce/test_admin.py | 137 +++++ backend/tests/finance/test_analytics.py | 327 +++++++++++ backend/tests/finance/test_projects.py | 300 +++++++++++ docs/README.md | 1 + docs/guides/COMMERCE.md | 23 + docs/guides/COMPANY_ANALYTICS.md | 216 ++++++++ frontend/src/app/finances/analytics/page.tsx | 5 + frontend/src/app/finances/page.tsx | 34 +- frontend/src/app/globals.css | 67 ++- frontend/src/app/products/layout.tsx | 6 + frontend/src/app/search/page.tsx | 1 - frontend/src/components/AppShell.tsx | 5 +- .../analytics/AnalyticsHelpButton.tsx | 20 + .../analytics/AnalyticsHelpContent.tsx | 93 ++++ .../components/analytics/AnalyticsLayout.tsx | 21 + .../analytics/AnalyticsListSection.tsx | 98 ++++ .../analytics/AnalyticsRecordCard.tsx | 41 ++ .../analytics/AnalyticsWorkspace.tsx | 336 ++++++++++++ .../src/components/analytics/DatasetChart.tsx | 72 +++ .../components/analytics/ProjectsReport.tsx | 252 +++++++++ .../components/analytics/ReportSections.tsx | 347 ++++++++++++ .../src/components/analytics/ReportTable.tsx | 171 ++++++ .../components/analytics/ReportingJournal.tsx | 509 ++++++++++++++++++ .../commerce/ProductConnectionsContent.tsx | 2 + frontend/src/hooks/usePayrollTabs.ts | 11 +- frontend/src/lib/analytics.test.js | 70 +++ frontend/src/lib/analytics.ts | 77 +++ frontend/src/lib/api/analytics.ts | 47 ++ frontend/src/lib/api/index.ts | 2 + frontend/src/types/analytics.ts | 102 ++++ frontend/src/types/commerce.ts | 6 + 59 files changed, 5457 insertions(+), 31 deletions(-) create mode 100644 backend/api/v1/finance/analytics.py create mode 100644 backend/commerce/forms.py create mode 100644 backend/commerce/management/commands/sync_moysklad_projects.py create mode 100644 backend/commerce/migrations/0012_sale_customer_external_id_sale_customer_name.py create mode 100644 backend/commerce/migrations/0013_restore_sale_customers.py create mode 100644 backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py create mode 100644 backend/commerce/models/projects.py create mode 100644 backend/commerce/services/moysklad_projects.py create mode 100644 backend/finance/analytics/__init__.py create mode 100644 backend/finance/analytics/models.py create mode 100644 backend/finance/analytics/people.py create mode 100644 backend/finance/analytics/projects.py create mode 100644 backend/finance/analytics/sales.py create mode 100644 backend/finance/migrations/0032_reportingentry.py create mode 100644 backend/finance/migrations/0033_reportingentry_project.py create mode 100644 backend/tests/commerce/test_admin.py create mode 100644 backend/tests/finance/test_analytics.py create mode 100644 backend/tests/finance/test_projects.py create mode 100644 docs/guides/COMPANY_ANALYTICS.md create mode 100644 frontend/src/app/finances/analytics/page.tsx create mode 100644 frontend/src/app/products/layout.tsx create mode 100644 frontend/src/components/analytics/AnalyticsHelpButton.tsx create mode 100644 frontend/src/components/analytics/AnalyticsHelpContent.tsx create mode 100644 frontend/src/components/analytics/AnalyticsLayout.tsx create mode 100644 frontend/src/components/analytics/AnalyticsListSection.tsx create mode 100644 frontend/src/components/analytics/AnalyticsRecordCard.tsx create mode 100644 frontend/src/components/analytics/AnalyticsWorkspace.tsx create mode 100644 frontend/src/components/analytics/DatasetChart.tsx create mode 100644 frontend/src/components/analytics/ProjectsReport.tsx create mode 100644 frontend/src/components/analytics/ReportSections.tsx create mode 100644 frontend/src/components/analytics/ReportTable.tsx create mode 100644 frontend/src/components/analytics/ReportingJournal.tsx create mode 100644 frontend/src/lib/analytics.test.js create mode 100644 frontend/src/lib/analytics.ts create mode 100644 frontend/src/lib/api/analytics.ts create mode 100644 frontend/src/types/analytics.ts diff --git a/.gitignore b/.gitignore index 26dd183b..603627e6 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,6 @@ dump.rdb .playwright-cli/ .ruff_cache/ .pytest_cache/ + +# Local planning documents and source materials +/project-planning/ diff --git a/backend/api/v1/commerce/serializers.py b/backend/api/v1/commerce/serializers.py index 8014d69f..c13fc9f3 100644 --- a/backend/api/v1/commerce/serializers.py +++ b/backend/api/v1/commerce/serializers.py @@ -136,7 +136,9 @@ class IntegrationConnectionSerializer(serializers.ModelSerializer): provider = attrs.get("provider", getattr(self.instance, "provider", None)) if provider == IntegrationProvider.WILDBERRIES and auth_type != "token": raise serializers.ValidationError( - {"auth_type": "Wildberries поддерживает авторизацию только по API-токену."} + { + "auth_type": "Wildberries поддерживает авторизацию только по API-токену." + } ) has_new_credentials = any( key in attrs for key in ("token", "login", "password") @@ -327,6 +329,15 @@ class SaleSerializer(serializers.ModelSerializer): source_code = serializers.CharField(source="source.code", read_only=True) lines = SaleLineSerializer(many=True, read_only=True) + def validate(self, attrs): + project = attrs.get("project", getattr(self.instance, "project", None)) + source = attrs.get("source", getattr(self.instance, "source", None)) + if project and source and project.source_id != source.pk: + raise serializers.ValidationError( + {"project": "Проект относится к другому источнику."} + ) + return attrs + class Meta: model = Sale fields = [ @@ -338,6 +349,9 @@ class SaleSerializer(serializers.ModelSerializer): "number", "event_type", "channel", + "customer_external_id", + "customer_name", + "project", "status", "counts_as_sale", "currency", @@ -360,6 +374,7 @@ class CostSerializer(serializers.ModelSerializer): "source_code", "product", "sale_line", + "project", "external_id", "channel", "cost_type", @@ -374,9 +389,23 @@ class CostSerializer(serializers.ModelSerializer): def validate(self, attrs): product = attrs.get("product", getattr(self.instance, "product", None)) sale_line = attrs.get("sale_line", getattr(self.instance, "sale_line", None)) - if product is None and sale_line is None: + project = attrs.get("project", getattr(self.instance, "project", None)) + source = attrs.get("source", getattr(self.instance, "source", None)) + if project and source and project.source_id != source.pk: raise serializers.ValidationError( - "Нужно указать товар или позицию продажи." + {"project": "Проект относится к другому источнику."} + ) + if ( + project + and sale_line + and sale_line.sale.project_id not in [None, project.pk] + ): + raise serializers.ValidationError( + {"project": "Проект не совпадает с проектом продажи."} + ) + if product is None and sale_line is None and project is None: + raise serializers.ValidationError( + "Нужно указать товар, позицию продажи или проект." ) external_id = attrs.get( "external_id", getattr(self.instance, "external_id", "") diff --git a/backend/api/v1/finance/analytics.py b/backend/api/v1/finance/analytics.py new file mode 100644 index 00000000..f1e1e896 --- /dev/null +++ b/backend/api/v1/finance/analytics.py @@ -0,0 +1,332 @@ +from datetime import date +from decimal import Decimal + +from django.db import transaction +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import generics, permissions, serializers +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 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.sales import production_report, sales_report +from finance.payroll.access import ( + can_access_payroll_management, + has_simple_admin_access, +) +from .views import NoStorePayrollResponseMixin + + +class AnalyticsPermission(permissions.BasePermission): + def has_permission(self, request, view): + return can_access_payroll_management(request.user) + + +def can_write(user): + return has_simple_admin_access(user) or user.has_perm("finance.add_reportingentry") + + +class ReportQuery(serializers.Serializer): + project = serializers.CharField(required=False) + date_from = serializers.DateField(required=False) + date_to = serializers.DateField(required=False) + currency = serializers.RegexField(r"^[A-Z]{3}$", default="RUB") + source = serializers.PrimaryKeyRelatedField( + queryset=SourceSystem.objects.all(), required=False + ) + + 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): + raise serializers.ValidationError({"project": "Проект не найден."}) + if attrs.get("source") and attrs["project"].source_id != attrs["source"].pk: + raise serializers.ValidationError( + {"project": "Проект относится к другому источнику."} + ) + today = timezone.localdate() + attrs.setdefault("date_from", today.replace(month=1, day=1)) + attrs.setdefault("date_to", today) + if attrs["date_from"] > attrs["date_to"]: + raise serializers.ValidationError( + "Начало периода должно быть не позже окончания." + ) + if attrs["date_to"].year >= 9999: + raise serializers.ValidationError( + "Дата окончания выходит за поддерживаемый диапазон." + ) + if (attrs["date_to"] - attrs["date_from"]).days > 366: + raise serializers.ValidationError("Выберите период не более 367 дней.") + return attrs + + +def report_params(request): + query = ReportQuery(data=request.query_params) + query.is_valid(raise_exception=True) + return query.validated_data + + +def json_values(value): + if isinstance(value, Decimal): + return str(value.quantize(Decimal("0.0001"))) + if isinstance(value, date): + return value.isoformat() + if isinstance(value, dict): + return {key: json_values(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [json_values(item) for item in value] + return value + + +class AnalyticsReportView(NoStorePayrollResponseMixin, APIView): + permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] + + def get(self, request): + 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": "Неизвестный раздел отчёта."}) + result = { + "period": {"from": start, "to": end}, + "currency": currency, + "generated_at": timezone.now(), + "can_write": can_write(request.user), + } + if section == "people": + result["people"] = people_report(start, end, currency) + else: + product_ids = None + if section == "production": + production_entries = ReportingEntry.objects.filter( + kind="production", + date__lte=end, + voided_at__isnull=True, + ) + if params.get("project") is not None: + production_entries = production_entries.filter( + project_id=None + if params["project"] == "unassigned" + else params["project"].pk + ) + product_ids = production_entries.values_list( + "product_id", flat=True + ).distinct() + sales = sales_report( + start, + end, + currency, + params.get("source"), + product_ids, + params.get("project"), + ) + result["sales"] = sales + result["production"] = production_report( + start, end, currency, sales, params.get("project") + ) + if section == "projects": + result["projects"] = projects_report( + start, + end, + currency, + sales, + params.get("source"), + params.get("project"), + ) + return Response(json_values(result)) + + +class ReportingEntrySerializer(serializers.ModelSerializer): + project_name = serializers.CharField( + source="project.name", read_only=True, default="" + ) + product_name = serializers.CharField( + source="product.name", read_only=True, default="" + ) + employee_name = serializers.SerializerMethodField() + + def get_employee_name(self, obj): + return obj.employee.get_full_name() if obj.employee_id else "" + + class Meta: + model = ReportingEntry + fields = [ + "id", + "kind", + "date", + "reference", + "description", + "project", + "project_name", + "product", + "product_name", + "employee", + "employee_name", + "quantity", + "amount", + "currency", + "created_at", + "created_by", + "voided_at", + "voided_by", + ] + read_only_fields = ["created_at", "created_by", "voided_at", "voided_by"] + + def validate(self, attrs): + kind = attrs["kind"] + product, employee = attrs.get("product"), attrs.get("employee") + amount, quantity = attrs.get("amount", 0), attrs.get("quantity", 0) + if kind == "production": + if not product or employee or quantity <= 0 or amount != 0: + raise serializers.ValidationError( + "Для выпуска укажите товар и положительное количество; сумма и сотрудник не требуются." + ) + elif kind == "purchase": + if employee or amount <= 0: + raise serializers.ValidationError( + "Для закупки нужна положительная сумма; сотрудник не указывается." + ) + elif kind == "tax": + if not employee or product or quantity != 0 or amount <= 0: + raise serializers.ValidationError( + "Для налога укажите сотрудника и положительную сумму, без товара и количества." + ) + elif product or employee or quantity != 0 or amount <= 0: + raise serializers.ValidationError( + "Укажите положительную сумму, без товара, сотрудника и количества." + ) + if attrs["date"] > timezone.localdate(): + raise serializers.ValidationError( + {"date": "Факт не может быть датирован будущим числом."} + ) + return attrs + + +class ReportingEntryListView(NoStorePayrollResponseMixin, generics.ListCreateAPIView): + permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] + serializer_class = ReportingEntrySerializer + + def get_queryset(self): + params = report_params(self.request) + qs = ReportingEntry.objects.filter( + date__range=(params["date_from"], params["date_to"]), + currency=params["currency"], + ).select_related("product", "employee", "project") + if params.get("project") is not None: + qs = qs.filter( + project_id=None + if params["project"] == "unassigned" + else params["project"].pk + ) + return qs + + def perform_create(self, serializer): + if not can_write(self.request.user): + raise PermissionDenied("Нет права добавлять факты для аналитики.") + serializer.save(created_by=self.request.user) + + +class ReportingEntryVoidView(NoStorePayrollResponseMixin, APIView): + permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] + + @transaction.atomic + def post(self, request, pk): + if not ( + has_simple_admin_access(request.user) + or request.user.has_perm("finance.change_reportingentry") + ): + raise PermissionDenied("Нет права аннулировать факты для аналитики.") + entry = get_object_or_404(ReportingEntry.objects.select_for_update(), pk=pk) + if not entry.voided_at: + entry.voided_at = timezone.now() + entry.voided_by = request.user + entry.save(update_fields=["voided_at", "voided_by"]) + return Response(ReportingEntrySerializer(entry).data) + + +class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView): + permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] + + def get(self, request): + search = request.query_params.get("search", "").strip()[:100] + products = Product.objects.order_by("name", "pk") + employees = Employee.objects.order_by("last_name", "first_name", "pk") + if search: + products = products.filter( + Q(name__icontains=search) | Q(sku__icontains=search) + ) + employees = employees.filter( + Q(first_name__icontains=search) | Q(last_name__icontains=search) + ) + return Response( + { + "products": [ + {"id": str(p.pk), "name": f"{p.name} · {p.sku}"} + for p in products[:100] + ], + "employees": [ + {"id": p.pk, "name": p.get_full_name()} for p in employees[:100] + ], + "sources": list( + SourceSystem.objects.order_by("name").values("id", "name") + ), + "projects": list( + ExternalProject.objects.values( + "id", "name", "source_id", "archived" + ) + ), + "can_write": can_write(request.user), + "can_void": has_simple_admin_access(request.user) + or request.user.has_perm("finance.change_reportingentry"), + } + ) + + +class ProjectPaymentSerializer(serializers.ModelSerializer): + project_name = serializers.CharField( + source="project.name", default="Без проекта", read_only=True + ) + source_name = serializers.CharField(source="source.name", read_only=True) + + class Meta: + model = ProjectPayment + fields = [ + "id", + "project", + "project_name", + "source_name", + "document_type", + "number", + "description", + "expense_category", + "payee", + "amount", + "currency", + "occurred_at", + ] + + +class ProjectPaymentListView(NoStorePayrollResponseMixin, generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] + serializer_class = ProjectPaymentSerializer + + def get_queryset(self): + p = report_params(self.request) + return payment_queryset( + p["date_from"], + p["date_to"], + p["currency"], + p.get("source"), + p.get("project"), + ) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 1e81bfdd..df509aca 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -1,4 +1,11 @@ from django.urls import path +from .analytics import ( + ProjectPaymentListView, + AnalyticsOptionsView, + AnalyticsReportView, + ReportingEntryListView, + ReportingEntryVoidView, +) from .views import ( OwnPayrollStatementAcknowledgeView, @@ -58,6 +65,23 @@ from .admin import ( app_name = "finance-payroll" urlpatterns = [ + path( + "analytics/project-payments/", + ProjectPaymentListView.as_view(), + name="analytics-project-payments", + ), + path("analytics/report/", AnalyticsReportView.as_view(), name="analytics-report"), + path( + "analytics/options/", AnalyticsOptionsView.as_view(), name="analytics-options" + ), + path( + "analytics/entries/", ReportingEntryListView.as_view(), name="analytics-entries" + ), + path( + "analytics/entries//void/", + ReportingEntryVoidView.as_view(), + name="analytics-entry-void", + ), path( "payroll/management-access/", PayrollManagementAccessSettingsView.as_view(), diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 987fdc55..6eb9072a 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -587,7 +587,11 @@ def search_api_view(request: DRFRequest) -> Response: # Проверяем весь набор совпадений: раннее обрезание общего индекса приводило # к тому, что многочисленные недоступные записи вытесняли доступные из выдачи. - search_results = watson_search(query) + # Модуль товаров отложен; индекс сохраняем для будущего интерфейса, + # но не предлагаем пользователям переходы на скрытые страницы. + search_results = watson_search(query).exclude( + content_type__app_label="commerce", content_type__model="product" + ) search_objects = _materialize_search_objects(search_results) # Группируем и форматируем результаты diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py index 6959f721..cc307be2 100644 --- a/backend/commerce/admin.py +++ b/backend/commerce/admin.py @@ -1,12 +1,28 @@ -from django.contrib import admin +from datetime import timedelta + +from django.contrib import admin, messages +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.debug import sensitive_post_parameters + +from .forms import IntegrationConnectionAdminForm +from .integrations.moysklad import MoySkladError +from .integrations.wildberries import WildberriesError +from .services import test_moysklad_connection, test_wildberries_connection +from .services.credentials import CredentialError +from .tasks import enqueue_connection_sync from .models import ( Cost, + ExternalProject, IntegrationConnection, + IntegrationProvider, + IntegrationStatus, IntegrationSyncRun, Product, ProductBinding, ProductImage, + ProjectPayment, Sale, SaleLine, SourceRecord, @@ -17,13 +33,123 @@ from .models import ( @admin.register(IntegrationConnection) class IntegrationConnectionAdmin(admin.ModelAdmin): - list_display = ["provider", "source", "status", "account_name", "updated_at"] + form = IntegrationConnectionAdminForm + list_display = [ + "provider", "source", "status", "account_name", "auto_sync", + "last_sync_finished_at", "next_sync_at", + ] list_filter = ["provider", "status"] - readonly_fields = ["encrypted_credentials", "last_stats", "task_id"] + search_fields = ["source__name", "account_name"] + readonly_fields = [ + "source", "status", "account_name", "last_error", "last_tested_at", + "last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id", + "next_sync_at", "created_by", "created_at", "updated_at", + "credentials_configured", + ] + fieldsets = [ + ("Подключение", {"fields": ["provider", "source", "auth_type", "credentials_configured"]}), + ("Реквизиты", {"fields": ["token", "login", "password"]}), + ("Расписание", {"fields": ["auto_sync", "sync_interval_minutes", "next_sync_at"]}), + ("Состояние", {"fields": [ + "status", "account_name", "last_error", "last_tested_at", + "last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id", + ]}), + ("История", {"fields": ["created_by", "created_at", "updated_at"], "classes": ["collapse"]}), + ] + actions = ["test_connections", "sync_connections"] + + @admin.display(boolean=True, description="Реквизиты сохранены") + def credentials_configured(self, obj): + return bool(obj.encrypted_credentials) + + @method_decorator(sensitive_post_parameters("token", "login", "password")) + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): + return super().changeform_view(request, object_id, form_url, extra_context) + + def save_model(self, request, obj, form, change): + if not change: + obj.source, _ = SourceSystem.objects.get_or_create( + code=obj.provider, + defaults={"name": IntegrationProvider(obj.provider).label}, + ) + obj.created_by = request.user + super().save_model(request, obj, form, change) + + @admin.action(description="Проверить выбранные подключения", permissions=["change"]) + def test_connections(self, request, queryset): + for connection in queryset: + if connection.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}: + self.message_user(request, "Дождитесь завершения синхронизации.", messages.WARNING) + continue + test = { + "moysklad": test_moysklad_connection, + "wildberries": test_wildberries_connection, + }.get(connection.provider) + if test is None: + self.message_user(request, "Этот сервис пока не поддерживается.", messages.ERROR) + continue + try: + context = test(connection) + except (MoySkladError, WildberriesError, CredentialError) as exc: + connection.status = IntegrationStatus.ERROR + connection.last_error = str(exc) + connection.last_tested_at = timezone.now() + connection.save(update_fields=["status", "last_error", "last_tested_at", "updated_at"]) + self.message_user(request, f"{connection.get_provider_display()}: {exc}", messages.ERROR) + continue + connection.status = IntegrationStatus.READY + connection.account_name = context["account_name"] + connection.last_error = "" + connection.last_tested_at = timezone.now() + connection.next_sync_at = ( + timezone.now() + timedelta(minutes=connection.sync_interval_minutes) + if connection.auto_sync else None + ) + connection.source.metadata = { + **connection.source.metadata, "account_id": context["account_id"], + } + if context.get("tin"): + connection.source.metadata["tin"] = context["tin"] + connection.source.save(update_fields=["metadata", "updated_at"]) + connection.save(update_fields=[ + "status", "account_name", "last_error", "last_tested_at", "next_sync_at", "updated_at", + ]) + self.message_user(request, f"{connection.get_provider_display()}: подключение проверено.") + + @admin.action(description="Синхронизировать выбранные подключения", permissions=["change"]) + def sync_connections(self, request, queryset): + for connection in queryset: + if not connection.encrypted_credentials: + self.message_user(request, "Сначала сохраните реквизиты подключения.", messages.ERROR) + continue + try: + run = enqueue_connection_sync(connection) + except Exception: + # Публикация задачи уже сохраняет ошибку в подключении и журнале. + self.message_user(request, "Не удалось запустить синхронизацию. Проверьте историю запусков.", messages.ERROR) + continue + if run is None: + self.message_user(request, "Синхронизация уже выполняется.", messages.WARNING) + else: + self.message_user(request, f"{connection.get_provider_display()}: синхронизация поставлена в очередь.") + + +class ImportedDataAdmin(admin.ModelAdmin): + def get_readonly_fields(self, request, obj=None): + return [field.name for field in self.model._meta.fields] + + 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.register(IntegrationSyncRun) -class IntegrationSyncRunAdmin(admin.ModelAdmin): +class IntegrationSyncRunAdmin(ImportedDataAdmin): list_display = [ "connection", "status", @@ -38,6 +164,21 @@ class IntegrationSyncRunAdmin(admin.ModelAdmin): readonly_fields = [field.name for field in IntegrationSyncRun._meta.fields] +@admin.register(ExternalProject) +class ExternalProjectAdmin(ImportedDataAdmin): + list_display = ["name", "code", "source", "archived", "synced_at"] + list_filter = ["source", "archived"] + search_fields = ["name", "code", "external_id"] + + +@admin.register(ProjectPayment) +class ProjectPaymentAdmin(ImportedDataAdmin): + list_display = ["number", "document_type", "project", "amount", "currency", "occurred_at", "applicable"] + list_filter = ["source", "document_type", "currency", "applicable", "missing_from_source"] + search_fields = ["number", "external_id", "project__name", "payee", "expense_category"] + list_select_related = ["project", "source"] + + class ProductBindingInline(admin.TabularInline): model = ProductBinding extra = 0 diff --git a/backend/commerce/forms.py b/backend/commerce/forms.py new file mode 100644 index 00000000..91d34893 --- /dev/null +++ b/backend/commerce/forms.py @@ -0,0 +1,72 @@ +from datetime import timedelta + +from django import forms +from django.utils import timezone + +from commerce.models import IntegrationConnection, IntegrationStatus +from commerce.services.credentials import encrypt_credentials + + +class IntegrationConnectionAdminForm(forms.ModelForm): + token = forms.CharField( + label="Новый API-токен", required=False, + widget=forms.PasswordInput(render_value=False), + help_text="Оставьте пустым, чтобы сохранить текущие реквизиты.", + ) + login = forms.CharField(label="Новый API-логин", required=False) + password = forms.CharField( + label="Новый пароль", required=False, strip=False, + widget=forms.PasswordInput(render_value=False), + help_text="Для смены реквизитов укажите и логин, и пароль.", + ) + + class Meta: + model = IntegrationConnection + fields = [ + "provider", "auth_type", "token", "login", "password", + "auto_sync", "sync_interval_minutes", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance._state.adding and "provider" in self.fields: + self.fields["provider"].disabled = True + + def clean(self): + data = super().clean() + if self.instance.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}: + raise forms.ValidationError("Дождитесь завершения текущей синхронизации.") + auth_type = data.get("auth_type") + if data.get("provider") == "wildberries" and auth_type != "token": + self.add_error("auth_type", "Wildberries поддерживает только API-токен.") + credentials_required = ( + not self.instance.encrypted_credentials + or auth_type != self.instance.auth_type + or any(data.get(key) for key in ("token", "login", "password")) + ) + self.new_credentials = None + if credentials_required: + keys = ("token",) if auth_type == "token" else ("login", "password") + for key in keys: + if not data.get(key): + self.add_error(key, "Укажите реквизиты выбранного способа авторизации.") + if all(data.get(key) for key in keys): + self.new_credentials = {key: data[key] for key in keys} + return data + + def save(self, commit=True): + connection = super().save(commit=False) + if self.new_credentials: + connection.encrypted_credentials = encrypt_credentials(self.new_credentials) + connection.status = IntegrationStatus.DISCONNECTED + connection.last_error = "" + connection.last_tested_at = None + if {"auto_sync", "sync_interval_minutes"}.intersection(self.changed_data): + connection.next_sync_at = ( + timezone.now() + timedelta(minutes=connection.sync_interval_minutes) + if connection.auto_sync else None + ) + if commit: + connection.save() + self.save_m2m() + return connection diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py index a3ac8d8b..abcb1e05 100644 --- a/backend/commerce/integrations/moysklad.py +++ b/backend/commerce/integrations/moysklad.py @@ -36,6 +36,7 @@ class MoySkladClient: READ_TIMEOUT_SECONDS = 90.0 def __init__(self, *, auth_type: str, credentials: dict[str, str]): + self._project_reference_cache = {} self.base_url = ( getattr(settings, "MOYSKLAD_API_BASE_URL", self.DEFAULT_BASE_URL).rstrip( "/" @@ -139,6 +140,22 @@ class MoySkladClient: ) time.sleep(retry_delay) except httpx.HTTPStatusError as exc: + if ( + exc.response.status_code in {429, 500, 502, 503, 504} + and attempt < total_attempts + ): + retry_delay = self.GET_RETRY_DELAYS[attempt - 1] + logger.warning( + "MoySklad HTTP %s; retrying GET", + exc.response.status_code, + extra={ + "endpoint": endpoint, + "attempt": attempt, + "request_offset": (params or {}).get("offset"), + }, + ) + time.sleep(retry_delay) + continue raise MoySkladError(self._http_status_message(exc.response)) from exc except (httpx.HTTPError, ValueError) as exc: logger.error( @@ -167,9 +184,7 @@ class MoySkladClient: if status in {401, 403}: message = "МойСклад отклонил реквизиты или у пользователя нет прав API." elif status == 400: - message = ( - "МойСклад не принял запрос. Проверьте access token и права пользователя." - ) + message = "МойСклад не принял запрос. Проверьте access token и права пользователя." elif status == 429: message = "МойСклад временно ограничил частоту запросов." else: @@ -236,7 +251,9 @@ class MoySkladClient: for chunk in response.iter_bytes(): received += len(chunk) if received > self.MAX_IMAGE_BYTES: - raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.") + raise MoySkladError( + "Изображение МойСклад превышает лимит 25 МБ." + ) chunks.append(chunk) content_type = response.headers.get("Content-Type", "").split(";", 1)[0] except MoySkladError: @@ -360,7 +377,7 @@ class MoySkladClient: params={ "limit": limit, "offset": offset, - "expand": "positions.assortment,state,salesChannel,rate.currency", + "expand": "positions.assortment,state,salesChannel,rate.currency,agent,project", }, ) rows = payload.get("rows", []) @@ -373,6 +390,66 @@ class MoySkladClient: return offset += limit + def _payment_reference(self, reference): + if not reference or reference.get("name"): + return reference or {} + meta = reference.get("meta") or {} + kind = meta.get("type") + external_id = ( + reference.get("id") + or urlparse(meta.get("href", "")).path.rstrip("/").split("/")[-1] + ) + if kind not in { + "counterparty", + "organization", + "employee", + "expenseitem", + "currency", + } or not re.fullmatch(r"[0-9a-fA-F-]{36}", external_id): + raise MoySkladError("Некорректная ссылка в платеже МойСклад.") + key = (kind, external_id) + if key not in self._project_reference_cache: + data = self._get(f"/entity/{kind}/{external_id}") + self._project_reference_cache[key] = { + k: data[k] + for k in ["id", "meta", "name", "isoCode", "code"] + if k in data + } + return self._project_reference_cache[key] + + def _payment_references(self, row): + # Resolve shared references once per run instead of repeatedly asking + # the API to expand hundreds of the same objects on every large page. + row = dict(row) + for key in ["expenseItem", "agent"]: + row[key] = self._payment_reference(row.get(key)) + rate = dict(row.get("rate") or {}) + rate["currency"] = self._payment_reference(rate.get("currency")) + row["rate"] = rate + return row + + def iter_project_data( + self, entity_type: str, *, archived=False, limit=1000 + ) -> Iterator[dict]: + if entity_type not in {"project", "paymentout", "cashout"}: + raise MoySkladError("Неподдерживаемый тип данных проекта.") + offset = 0 + while True: + params = {"limit": limit, "offset": offset} + if entity_type == "project": + params["filter"] = f"archived={str(archived).lower()}" + payload = self._get(f"/entity/{entity_type}", params=params) + rows = payload.get("rows") + if not isinstance(rows, list): + raise MoySkladError("В ответе проектов отсутствует список rows.") + for row in rows: + if not isinstance(row, dict): + raise MoySkladError("Некорректная запись проекта.") + yield row if entity_type == "project" else self._payment_references(row) + if len(rows) < limit: + return + offset += limit + def iter_stock_by_store(self, *, limit: int = 1000) -> Iterator[dict]: offset = 0 while True: diff --git a/backend/commerce/management/commands/sync_moysklad_projects.py b/backend/commerce/management/commands/sync_moysklad_projects.py new file mode 100644 index 00000000..e3187a92 --- /dev/null +++ b/backend/commerce/management/commands/sync_moysklad_projects.py @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand, CommandError + +from commerce.integrations.moysklad import MoySkladClient, MoySkladError +from commerce.models import IntegrationConnection +from commerce.services.credentials import decrypt_credentials +from commerce.services.moysklad_projects import ( + sync_project_catalog, + sync_project_payments, +) + + +class Command(BaseCommand): + help = "Загрузить проекты и исходящие платежи МойСклад без каталога товаров и изображений." + + def add_arguments(self, parser): + parser.add_argument("connection_id") + + def handle(self, *args, **options): + connection = IntegrationConnection.objects.filter( + pk=options["connection_id"], provider="moysklad" + ).first() + if not connection: + raise CommandError("Подключение МойСклад не найдено.") + try: + with MoySkladClient( + auth_type=connection.auth_type, + credentials=decrypt_credentials(connection), + ) as client: + projects = sync_project_catalog(connection.source, client) + payments = sync_project_payments( + connection.source, + client, + progress=lambda count: ( + self.stdout.write(f"Платежей обработано: {count}") + if count % 1000 == 0 + else None + ), + ) + except MoySkladError as exc: + raise CommandError(str(exc)) from exc + self.stdout.write( + self.style.SUCCESS(f"Проекты: {projects}; исходящие платежи: {payments}.") + ) diff --git a/backend/commerce/migrations/0012_sale_customer_external_id_sale_customer_name.py b/backend/commerce/migrations/0012_sale_customer_external_id_sale_customer_name.py new file mode 100644 index 00000000..bd6aaa6e --- /dev/null +++ b/backend/commerce/migrations/0012_sale_customer_external_id_sale_customer_name.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.4 on 2026-09-07 06:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commerce", "0011_integrationsyncrun_progress_heartbeat"), + ] + + operations = [ + migrations.AddField( + model_name="sale", + name="customer_external_id", + field=models.CharField( + blank=True, max_length=255, verbose_name="ID клиента в источнике" + ), + ), + migrations.AddField( + model_name="sale", + name="customer_name", + field=models.CharField(blank=True, max_length=255, verbose_name="Клиент"), + ), + ] diff --git a/backend/commerce/migrations/0013_restore_sale_customers.py b/backend/commerce/migrations/0013_restore_sale_customers.py new file mode 100644 index 00000000..626fb4ea --- /dev/null +++ b/backend/commerce/migrations/0013_restore_sale_customers.py @@ -0,0 +1,36 @@ +from django.db import migrations + + +def restore_customers(apps, schema_editor): + sale_model = apps.get_model("commerce", "Sale") + queryset = sale_model.objects.using(schema_editor.connection.alias).filter( + customer_external_id="", customer_name="" + ) + batch = [] + for pk, agent in ( + queryset.filter(source_record__isnull=False) + .values_list("pk", "source_record__payload__agent") + .iterator(chunk_size=500) + ): + if not isinstance(agent, dict): + continue + meta = agent.get("meta") or {} + href = str(meta.get("href") or "") if isinstance(meta, dict) else "" + customer_id = str(agent.get("id") or href.rstrip("/").rsplit("/", 1)[-1]) + batch.append( + sale_model( + pk=pk, + customer_external_id=customer_id[:255], + customer_name=str(agent.get("name") or "")[:255], + ) + ) + if len(batch) >= 500: + queryset.bulk_update(batch, ["customer_external_id", "customer_name"]) + batch = [] + if batch: + queryset.bulk_update(batch, ["customer_external_id", "customer_name"]) + + +class Migration(migrations.Migration): + dependencies = [("commerce", "0012_sale_customer_external_id_sale_customer_name")] + operations = [migrations.RunPython(restore_customers, migrations.RunPython.noop)] diff --git a/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py b/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py new file mode 100644 index 00000000..bc00ef1d --- /dev/null +++ b/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py @@ -0,0 +1,105 @@ +# Generated by Django 5.2.4 on 2026-09-07 07:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commerce', '0013_restore_sale_customers'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalProject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.CharField(max_length=255)), + ('name', models.CharField(max_length=255)), + ('code', models.CharField(blank=True, max_length=255)), + ('description', models.TextField(blank=True)), + ('archived', models.BooleanField(default=False)), + ('synced_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['name', 'pk'], + }, + ), + migrations.CreateModel( + name='ProjectPayment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.CharField(max_length=255)), + ('document_type', models.CharField(choices=[('paymentout', 'Исходящий платёж'), ('cashout', 'Расходный ордер')], max_length=16)), + ('number', models.CharField(blank=True, max_length=255)), + ('description', models.TextField(blank=True)), + ('expense_category', models.CharField(blank=True, max_length=255)), + ('payee', models.CharField(blank=True, max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=18)), + ('currency', models.CharField(max_length=3)), + ('occurred_at', models.DateTimeField(db_index=True)), + ('applicable', models.BooleanField(default=False)), + ('missing_from_source', models.BooleanField(default=False)), + ('seen_at', models.DateTimeField()), + ], + options={ + 'ordering': ['-occurred_at', '-pk'], + }, + ), + migrations.RemoveConstraint( + model_name='cost', + name='commerce_cost_has_target', + ), + migrations.AlterField( + model_name='integrationsyncrun', + name='stage', + field=models.CharField(blank=True, choices=[('projects', 'Проекты'), ('project_payments', 'Расходы проектов'), ('catalog', 'Каталог'), ('images', 'Изображения'), ('variant_links', 'Связи вариантов'), ('stocks', 'Остатки'), ('sales', 'Продажи'), ('returns', 'Возвраты'), ('finalizing', 'Завершение')], default='', max_length=32, verbose_name='Этап'), + ), + migrations.AddField( + model_name='externalproject', + name='source', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'), + ), + migrations.AddField( + model_name='cost', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + ), + migrations.AddField( + model_name='sale', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + ), + migrations.AddConstraint( + model_name='cost', + constraint=models.CheckConstraint(condition=models.Q(('product__isnull', False), ('sale_line__isnull', False), ('project__isnull', False), _connector='OR'), name='commerce_cost_has_target'), + ), + migrations.AddField( + model_name='projectpayment', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + ), + migrations.AddField( + model_name='projectpayment', + name='source', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'), + ), + migrations.AddField( + model_name='projectpayment', + name='source_record', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcerecord'), + ), + migrations.AddConstraint( + model_name='externalproject', + constraint=models.UniqueConstraint(fields=('source', 'external_id'), name='commerce_project_source_id'), + ), + migrations.AddConstraint( + model_name='projectpayment', + constraint=models.UniqueConstraint(fields=('source', 'document_type', 'external_id'), name='commerce_project_payment_id'), + ), + migrations.AddConstraint( + model_name='projectpayment', + constraint=models.CheckConstraint(condition=models.Q(('amount__gte', 0)), name='commerce_project_payment_amount'), + ), + ] diff --git a/backend/commerce/models/__init__.py b/backend/commerce/models/__init__.py index 1b1fbe89..7da276cc 100644 --- a/backend/commerce/models/__init__.py +++ b/backend/commerce/models/__init__.py @@ -1,5 +1,6 @@ from .costs import Cost, CostType from .product import Product, ProductImage, ProductKind +from .projects import ExternalProject, ProjectPayment from .sales import Sale, SaleEventType, SaleLine from .source import ( IntegrationConnection, @@ -14,6 +15,8 @@ from .source import ( from .stock import StockSnapshot __all__ = [ + "ExternalProject", + "ProjectPayment", "Cost", "CostType", "IntegrationConnection", diff --git a/backend/commerce/models/costs.py b/backend/commerce/models/costs.py index c3a5a0b2..af33d994 100644 --- a/backend/commerce/models/costs.py +++ b/backend/commerce/models/costs.py @@ -19,6 +19,9 @@ class Cost(models.Model): """Additional product- or sale-line-level expense.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey( + "commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT + ) source = models.ForeignKey( "commerce.SourceSystem", on_delete=models.PROTECT, @@ -57,7 +60,9 @@ class Cost(models.Model): verbose_name_plural = "Расходы" constraints = [ models.CheckConstraint( - condition=Q(product__isnull=False) | Q(sale_line__isnull=False), + condition=Q(product__isnull=False) + | Q(sale_line__isnull=False) + | Q(project__isnull=False), name="commerce_cost_has_target", ), models.UniqueConstraint( diff --git a/backend/commerce/models/projects.py b/backend/commerce/models/projects.py new file mode 100644 index 00000000..37357740 --- /dev/null +++ b/backend/commerce/models/projects.py @@ -0,0 +1,60 @@ +from django.db import models + + +class ExternalProject(models.Model): + source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT) + external_id = models.CharField(max_length=255) + name = models.CharField(max_length=255) + code = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + archived = models.BooleanField(default=False) + synced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["name", "pk"] + constraints = [ + models.UniqueConstraint( + fields=["source", "external_id"], name="commerce_project_source_id" + ) + ] + + def __str__(self): + return self.name + + +class ProjectPayment(models.Model): + """Outgoing cash documents; never automatically deducted from sale profit.""" + + source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT) + source_record = models.ForeignKey("commerce.SourceRecord", on_delete=models.PROTECT) + project = models.ForeignKey( + ExternalProject, null=True, blank=True, on_delete=models.PROTECT + ) + external_id = models.CharField(max_length=255) + document_type = models.CharField( + max_length=16, + choices=[("paymentout", "Исходящий платёж"), ("cashout", "Расходный ордер")], + ) + number = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + expense_category = models.CharField(max_length=255, blank=True) + payee = models.CharField(max_length=255, blank=True) + amount = models.DecimalField(max_digits=18, decimal_places=2) + currency = models.CharField(max_length=3) + occurred_at = models.DateTimeField(db_index=True) + applicable = models.BooleanField(default=False) + missing_from_source = models.BooleanField(default=False) + seen_at = models.DateTimeField() + + class Meta: + ordering = ["-occurred_at", "-pk"] + constraints = [ + models.UniqueConstraint( + fields=["source", "document_type", "external_id"], + name="commerce_project_payment_id", + ), + models.CheckConstraint( + condition=models.Q(amount__gte=0), + name="commerce_project_payment_amount", + ), + ] diff --git a/backend/commerce/models/sales.py b/backend/commerce/models/sales.py index 7b93d707..c72d234d 100644 --- a/backend/commerce/models/sales.py +++ b/backend/commerce/models/sales.py @@ -14,6 +14,9 @@ class Sale(models.Model): """Normalized sale/order event supplied by an external or internal system.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey( + "commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT + ) source = models.ForeignKey( "commerce.SourceSystem", on_delete=models.PROTECT, @@ -37,6 +40,10 @@ class Sale(models.Model): ) number = models.CharField("Номер", max_length=128, blank=True) channel = models.CharField("Канал продаж", max_length=64, blank=True, db_index=True) + customer_external_id = models.CharField( + "ID клиента в источнике", max_length=255, blank=True + ) + customer_name = models.CharField("Клиент", max_length=255, blank=True) status = models.CharField("Статус источника", max_length=128, blank=True) counts_as_sale = models.BooleanField( "Учитывать в продажах", diff --git a/backend/commerce/models/source.py b/backend/commerce/models/source.py index 30919be7..e32114d0 100644 --- a/backend/commerce/models/source.py +++ b/backend/commerce/models/source.py @@ -127,6 +127,8 @@ class IntegrationStatus(models.TextChoices): class IntegrationSyncStage(models.TextChoices): + PROJECTS = "projects", "Проекты" + PROJECT_PAYMENTS = "project_payments", "Расходы проектов" CATALOG = "catalog", "Каталог" IMAGES = "images", "Изображения" VARIANT_LINKS = "variant_links", "Связи вариантов" diff --git a/backend/commerce/services/moysklad_projects.py b/backend/commerce/services/moysklad_projects.py new file mode 100644 index 00000000..5608a09b --- /dev/null +++ b/backend/commerce/services/moysklad_projects.py @@ -0,0 +1,141 @@ +from django.db import transaction +from django.utils import timezone + +from commerce.integrations.moysklad import MoySkladError +from commerce.models import ExternalProject, ProjectPayment, Sale, SourceRecord +from commerce.services.ingestion import ingest_source_record +from commerce.services.moysklad_sync import ( + _document_currency, + _external_id, + _money, + _reference_external_id, + _source_updated_at, +) + + +def project_reference(source, row): + if not isinstance(row, dict): + return None + external_id = _reference_external_id(row) + if not external_id: + return None + project, _ = ExternalProject.objects.get_or_create( + source=source, + external_id=external_id, + defaults={"name": str(row.get("name") or external_id)[:255]}, + ) + # A reference must not erase information obtained from the full catalogue. + if row.get("name") and project.name != row["name"]: + project.name = str(row["name"])[:255] + project.save(update_fields=["name"]) + return project + + +@transaction.atomic +def normalize_project(source, row): + external_id = _external_id(row) + if not external_id or not row.get("name"): + raise MoySkladError("В проекте отсутствует ID или название.") + ingest_source_record( + source=source, + entity_type="project", + external_id=external_id, + payload=row, + source_updated_at=_source_updated_at(row), + ) + project, _ = ExternalProject.objects.update_or_create( + source=source, + external_id=external_id, + defaults={ + "name": str(row["name"])[:255], + "code": str(row.get("code") or "")[:255], + "description": str(row.get("description") or ""), + "archived": bool(row.get("archived")), + "synced_at": timezone.now(), + }, + ) + return project + + +@transaction.atomic +def normalize_project_payment(source, row, document_type, seen_at): + if document_type not in {"paymentout", "cashout"}: + raise MoySkladError("Неподдерживаемый платёж проекта.") + external_id = _external_id(row) + occurred_at = _source_updated_at({"updated": row.get("moment")}) + if not external_id or not occurred_at or row.get("sum") is None: + raise MoySkladError("В платеже отсутствует ID, дата или сумма.") + amount = _money(row["sum"]) + if amount < 0: + raise MoySkladError("Отрицательная сумма исходящего платежа.") + record = ingest_source_record( + source=source, + entity_type=document_type, + external_id=external_id, + payload=row, + source_updated_at=_source_updated_at(row), + ).record + payment, _ = ProjectPayment.objects.update_or_create( + source=source, + external_id=external_id, + document_type=document_type, + defaults={ + "source_record": record, + "project": project_reference(source, row.get("project")), + "number": str(row.get("name") or "")[:255], + "description": str( + row.get("paymentPurpose") or row.get("description") or "" + ), + "expense_category": str((row.get("expenseItem") or {}).get("name") or "")[ + :255 + ], + "payee": str((row.get("agent") or {}).get("name") or "")[:255], + "amount": amount, + "currency": _document_currency(row), + "occurred_at": occurred_at, + "applicable": bool(row.get("applicable", False)) and not row.get("deleted"), + "seen_at": seen_at, + "missing_from_source": False, + }, + ) + return payment + + +def restore_sale_projects(source): + count = 0 + records = SourceRecord.objects.filter( + source=source, entity_type__in=["demand", "salesreturn"] + ) + for record in records.iterator(chunk_size=500): + project = project_reference(source, record.payload.get("project")) + if project: + count += Sale.objects.filter( + source_record=record, project__isnull=True + ).update(project=project) + return count + + +def sync_project_catalog(source, client): + count = 0 + for archived in [False, True]: + for row in client.iter_project_data("project", archived=archived): + normalize_project(source, row) + count += 1 + restore_sale_projects(source) + return count + + +def sync_project_payments(source, client, progress=None): + count = 0 + for document_type in ["paymentout", "cashout"]: + seen_at = timezone.now() + for row in client.iter_project_data(document_type): + normalize_project_payment(source, row, document_type, seen_at) + count += 1 + if progress and count % 25 == 0: + progress(count) + # Only a fully successful scan may retire missing documents. Keep history. + ProjectPayment.objects.filter( + source=source, document_type=document_type, seen_at__lt=seen_at + ).update(missing_from_source=True) + return count diff --git a/backend/commerce/services/moysklad_sync.py b/backend/commerce/services/moysklad_sync.py index 3a20021e..192f2bdd 100644 --- a/backend/commerce/services/moysklad_sync.py +++ b/backend/commerce/services/moysklad_sync.py @@ -35,6 +35,8 @@ logger = logging.getLogger(__name__) @dataclass class MoySkladSyncStats: + projects: int = 0 + project_payments: int = 0 processed: int = 0 created_products: int = 0 matched_products: int = 0 @@ -571,6 +573,8 @@ def normalize_moysklad_document( document_type: str, channel_names: dict[str, str] | None = None, ) -> int: + from commerce.services.moysklad_projects import project_reference + source = connection.source source_id = _external_id(row) if not source_id: @@ -588,6 +592,7 @@ def normalize_moysklad_document( ) occurred_at = _source_updated_at({"updated": row.get("moment")}) or timezone.now() state = row.get("state") if isinstance(row.get("state"), dict) else {} + agent = row.get("agent") if isinstance(row.get("agent"), dict) else {} sale, _ = Sale.objects.update_or_create( source=source, external_id=external_id, @@ -596,6 +601,9 @@ def normalize_moysklad_document( "event_type": event_type, "number": str(row.get("name") or "")[:128], "channel": _document_channel(row, channel_names), + "customer_external_id": _reference_external_id(agent)[:255], + "customer_name": str(agent.get("name") or "")[:255], + "project": project_reference(source, row.get("project")), "status": str(state.get("name") or "")[:128], "counts_as_sale": bool(row.get("applicable", True)), "currency": _document_currency(row), @@ -839,6 +847,11 @@ def sync_moysklad_connection( run_id: str = "", task_id: str = "", ) -> MoySkladSyncStats: + from commerce.services.moysklad_projects import ( + sync_project_catalog, + sync_project_payments, + ) + credentials = decrypt_credentials(connection) stats = MoySkladSyncStats() captured_at = captured_at or timezone.now() @@ -855,6 +868,20 @@ def sync_moysklad_connection( with MoySkladClient( auth_type=connection.auth_type, credentials=credentials ) as client: + report(IntegrationSyncStage.PROJECTS, 0) + stats.projects = sync_project_catalog(connection.source, client) + report(IntegrationSyncStage.PROJECTS, stats.projects, stats.projects) + report(IntegrationSyncStage.PROJECT_PAYMENTS, 0) + stats.project_payments = sync_project_payments( + connection.source, + client, + progress=lambda count: report(IntegrationSyncStage.PROJECT_PAYMENTS, count), + ) + report( + IntegrationSyncStage.PROJECT_PAYMENTS, + stats.project_payments, + stats.project_payments, + ) image_rows = _sync_catalog_stage( connection, client, diff --git a/backend/finance/analytics/__init__.py b/backend/finance/analytics/__init__.py new file mode 100644 index 00000000..bb522021 --- /dev/null +++ b/backend/finance/analytics/__init__.py @@ -0,0 +1 @@ +"""Management reporting over commerce, payroll and explicitly recorded facts.""" diff --git a/backend/finance/analytics/models.py b/backend/finance/analytics/models.py new file mode 100644 index 00000000..559254a4 --- /dev/null +++ b/backend/finance/analytics/models.py @@ -0,0 +1,103 @@ +from decimal import Decimal + +from django.conf import settings +from django.core.validators import MinValueValidator, RegexValidator +from django.db import models + + +class ReportingEntry(models.Model): + """Supplementary, documented facts absent from the operational sources. + + Purchases and cash movements are not automatically expensed again in profit: + sales already carry their cost of goods. Corrections retain the audit trail. + """ + + class Kind(models.TextChoices): + PRODUCTION = "production", "Выпуск продукции" + PURCHASE = "purchase", "Закупка для производства" + TAX = "tax", "Уплаченные налоги сотрудника" + OVERHEAD = "overhead", "Общие расходы" + CASH_IN = "cash_in", "Поступление денег" + CASH_OUT = "cash_out", "Выплата денег" + + kind = models.CharField(max_length=16, choices=Kind.choices) + date = models.DateField("Дата факта", db_index=True) + reference = models.CharField("Документ / уникальный номер", max_length=200) + description = models.CharField("Описание", max_length=500, blank=True) + project = models.ForeignKey( + "commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT + ) + product = models.ForeignKey( + "commerce.Product", null=True, blank=True, on_delete=models.PROTECT + ) + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT + ) + quantity = models.DecimalField( + max_digits=18, + decimal_places=4, + default=0, + validators=[MinValueValidator(Decimal("0"))], + ) + amount = models.DecimalField( + max_digits=18, + decimal_places=2, + default=0, + validators=[MinValueValidator(Decimal("0"))], + ) + currency = models.CharField( + max_length=3, default="RUB", validators=[RegexValidator(r"^[A-Z]{3}$")] + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="reporting_entries_created", + ) + created_at = models.DateTimeField(auto_now_add=True) + voided_at = models.DateTimeField(null=True, blank=True) + voided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="reporting_entries_voided", + ) + + class Meta: + ordering = ["-date", "-pk"] + constraints = [ + models.UniqueConstraint( + fields=["kind", "reference"], name="reporting_entry_reference_unique" + ), + models.CheckConstraint( + condition=models.Q(amount__gte=0, quantity__gte=0), + name="reporting_entry_nonnegative", + ), + models.CheckConstraint( + condition=( + models.Q( + kind="production", + product__isnull=False, + employee__isnull=True, + quantity__gt=0, + amount=0, + ) + | models.Q(kind="purchase", employee__isnull=True, amount__gt=0) + | models.Q( + kind="tax", + employee__isnull=False, + product__isnull=True, + amount__gt=0, + quantity=0, + ) + | models.Q( + kind__in=["overhead", "cash_in", "cash_out"], + product__isnull=True, + employee__isnull=True, + amount__gt=0, + quantity=0, + ) + ), + name="reporting_entry_kind_fields", + ), + ] diff --git a/backend/finance/analytics/people.py b/backend/finance/analytics/people.py new file mode 100644 index 00000000..b4cfd1e9 --- /dev/null +++ b/backend/finance/analytics/people.py @@ -0,0 +1,235 @@ +from collections import defaultdict +from datetime import date, timedelta +from decimal import Decimal + +from django.db.models import OuterRef, Prefetch, Subquery +from django.utils import timezone + +from attendance.models import AttendanceRecord +from employees.models import Employee, EmployeeAction +from employees.services.personnel_state import ( + calculate_employee_tenure_days, + resolve_employee_personnel_state, +) +from finance.enums import PayrollRunStatus +from finance.models import PayrollRun, PayrollStatement +from finance.analytics.models import ReportingEntry +from .sales import ZERO, months + + +def mean(values): + return sum(values, ZERO) / len(values) if values else None + + +def absence_days(employee, actions, start, end): + vacation = sick = 0 + day = start + while day <= end: + status = ( + resolve_employee_personnel_state(employee, day, actions=actions).status + if actions + else "normal" + ) + vacation += int(status == "on_leave") + sick += int(status == "on_sick_leave") + day += timedelta(days=1) + return vacation, sick + + +def people_report(start, end, currency): + employees = list( + Employee.objects.prefetch_related( + Prefetch( + "actions", + queryset=EmployeeAction.objects.select_related( + "source_request" + ).order_by("date", "pk"), + ) + ).order_by("last_name", "first_name", "pk") + ) + latest_run = ( + PayrollRun.objects.filter( + period_id=OuterRef("run__period_id"), status=PayrollRunStatus.PUBLISHED + ) + .order_by("-revision") + .values("pk")[:1] + ) + statements = PayrollStatement.objects.filter( + run_id=Subquery(latest_run), + currency=currency, + run__period__date_from__gte=start, + run__period__date_to__lte=end, + ).select_related("run__period") + pay = defaultdict(lambda: defaultdict(lambda: ZERO)) + for statement in statements: + month = statement.run.period.date_to.strftime("%Y-%m") + pay[statement.employee_id][month] += statement.gross_total + + attendance = defaultdict(dict) + for record in AttendanceRecord.objects.filter( + date__range=(start, end), analysis_run__status="success" + ): + attendance[record.employee_id][record.date] = record + taxes = defaultdict(lambda: ZERO) + tax_counts = defaultdict(int) + for entry in ReportingEntry.objects.filter( + kind="tax", date__range=(start, end), currency=currency, voided_at__isnull=True + ): + taxes[entry.employee_id] += entry.amount + tax_counts[entry.employee_id] += 1 + + rows = [] + month_keys = list(months(start, end)) + comparison_months = sorted( + {month for employee_pay in pay.values() for month in employee_pay} + ) + demographic = {month: [] for month in month_keys} + for employee in employees: + actions = list(employee.actions.all()) + tenure = calculate_employee_tenure_days(employee, end, actions=actions) + had_future_hire = any( + a.action in ["hired", "rehired"] and timezone.localtime(a.date).date() > end + for a in actions + ) + state = resolve_employee_personnel_state(employee, end, actions=actions) + employed = (tenure is not None and state.status != "dismissed") or ( + tenure is None and employee.is_active and not had_future_hire + ) + salary = pay[employee.pk] + records = attendance[employee.pk] + employed_at_start = ( + calculate_employee_tenure_days(employee, start, actions=actions) is not None + and resolve_employee_personnel_state( + employee, start, actions=actions + ).status + != "dismissed" + ) + worked_during_period = (tenure or 0) > ( + calculate_employee_tenure_days(employee, start, actions=actions) or 0 + ) + if ( + not employed + and not employed_at_start + and not worked_during_period + and not salary + and not records + and not tax_counts[employee.pk] + ): + continue + vacation, sick = absence_days(employee, actions, start, end) + age = None + if employee.birth_date and employee.birth_date <= end: + born = employee.birth_date + age = end.year - born.year - ((end.month, end.day) < (born.month, born.day)) + first = salary.get(comparison_months[0]) if comparison_months else None + last = salary.get(comparison_months[-1]) if comparison_months else None + growth = ( + (last / first - 1) * 100 + if len(comparison_months) > 1 and first and last is not None + else None + ) + row = { + "id": employee.pk, + "name": " ".join( + filter( + None, [employee.last_name, employee.first_name, employee.patronymic] + ) + ), + "employed": employed, + "gender": employee.gender, + "age": age, + "tenure_days": tenure, + "salary_total": sum(salary.values(), ZERO), + "salary_months": len(salary), + "average_salary": mean(list(salary.values())), + "salary_growth_percent": growth, + "worked_days": sum( + 1 + for r in records.values() + if r.work_hours is not None and r.work_hours > 0 and not r.is_absent + ), + "attendance_days": len(records), + "vacation_days": vacation, + "sick_days": sick, + "tax_paid": taxes[employee.pk] if tax_counts[employee.pk] else None, + "tax_payments": tax_counts[employee.pk], + } + rows.append(row) + for month in month_keys: + month_start = date.fromisoformat(month + "-01") + as_of = min( + end, + (month_start.replace(day=28) + timedelta(days=4)).replace(day=1) + - timedelta(days=1), + ) + service = calculate_employee_tenure_days(employee, as_of, actions=actions) + if ( + service is not None + and resolve_employee_personnel_state( + employee, as_of, actions=actions + ).status + != "dismissed" + ): + born = employee.birth_date + historical_age = ( + as_of.year + - born.year + - ((as_of.month, as_of.day) < (born.month, born.day)) + if born and born <= as_of + else None + ) + demographic[month].append((service, historical_age, employee.gender)) + + current = [r for r in rows if r["employed"]] + comparable = [r for r in rows if r["salary_growth_percent"] is not None] + ordered = sorted(comparable, key=lambda r: (r["salary_growth_percent"], r["id"])) + monthly = [] + for month in month_keys: + salaries = [p[month] for p in pay.values() if month in p] + population = demographic[month] + monthly.append( + { + "id": month, + "average_salary": mean(salaries), + "salary_total": sum(salaries, ZERO), + "salary_employees": len(salaries), + "headcount_with_history": len(population), + "average_tenure_days": mean([Decimal(p[0]) for p in population]), + "average_age": mean( + [Decimal(p[1]) for p in population if p[1] is not None] + ), + "men": sum(p[2] == 1 for p in population), + "women": sum(p[2] == 2 for p in population), + } + ) + return { + "employees": rows, + "monthly": monthly, + "summary": { + "growth_from": comparison_months[0] if comparison_months else None, + "growth_to": comparison_months[-1] if comparison_months else None, + "headcount": len(current), + "men": sum(r["gender"] == 1 for r in current), + "women": sum(r["gender"] == 2 for r in current), + "unspecified_gender": sum(r["gender"] == 0 for r in current), + "average_age": mean( + [Decimal(r["age"]) for r in current if r["age"] is not None] + ), + "average_tenure_days": mean( + [ + Decimal(r["tenure_days"]) + for r in current + if r["tenure_days"] is not None + ] + ), + "missing_hire_dates": sum(r["tenure_days"] is None for r in current), + "missing_birth_dates": sum(r["age"] is None for r in current), + "average_salary": mean( + [value for p in pay.values() for value in p.values()] + ), + "salary_total": sum((sum(p.values(), ZERO) for p in pay.values()), ZERO), + "tax_paid": sum(taxes.values(), ZERO) if any(tax_counts.values()) else None, + "fastest_growth": ordered[-1]["id"] if ordered else None, + "slowest_growth": ordered[0]["id"] if ordered else None, + }, + } diff --git a/backend/finance/analytics/projects.py b/backend/finance/analytics/projects.py new file mode 100644 index 00000000..183bb380 --- /dev/null +++ b/backend/finance/analytics/projects.py @@ -0,0 +1,114 @@ +from collections import defaultdict +from django.utils import timezone + +from commerce.models import ExternalProject, ProjectPayment +from finance.analytics.models import ReportingEntry +from finance.analytics.sales import ZERO, bounds, bucket, finish, months + + +def payment_queryset(start, end, currency, source=None, project=None): + lower, upper = bounds(start, end) + qs = ProjectPayment.objects.filter( + currency=currency, + occurred_at__gte=lower, + occurred_at__lt=upper, + applicable=True, + missing_from_source=False, + ).select_related("project", "source") + if source: + qs = qs.filter(source=source) + if 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( + 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 + ) + 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 + 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["paid"] += p.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 + 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 + 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"] + } + summary["unassigned_paid"] = rows.get("unassigned", {}).get("paid", ZERO) + summary["project_count"] = catalog.count() + return { + "summary": summary, + "rows": sorted(rows.values(), key=lambda r: (-r["paid"], r["name"])), + "monthly": list(monthly.values()), + "categories": [ + {"id": k, "name": k, **v} for k, v in sorted(categories.items()) + ], + } diff --git a/backend/finance/analytics/sales.py b/backend/finance/analytics/sales.py new file mode 100644 index 00000000..14139f44 --- /dev/null +++ b/backend/finance/analytics/sales.py @@ -0,0 +1,261 @@ +from collections import defaultdict +from datetime import datetime, time, timedelta +from decimal import Decimal + +from django.db.models import Q +from django.utils import timezone + +from commerce.models import Cost, SaleLine +from finance.analytics.models import ReportingEntry + +ZERO = Decimal("0") + + +def bounds(start, end): + tz = timezone.get_current_timezone() + return ( + timezone.make_aware(datetime.combine(start, time.min), tz), + timezone.make_aware(datetime.combine(end + timedelta(days=1), time.min), tz), + ) + + +def bucket(): + return dict( + quantity=ZERO, + revenue=ZERO, + cost_of_goods=ZERO, + expenses=ZERO, + missing_cost_lines=0, + sale_lines=0, + ) + + +def finish(row): + row = dict(row) + row["gross_profit"] = row["revenue"] - row["cost_of_goods"] + row["contribution_profit"] = row["gross_profit"] - row["expenses"] + row["profit"] = ( + row["contribution_profit"] if not row["missing_cost_lines"] else None + ) + row["margin_percent"] = ( + row["profit"] / row["revenue"] * 100 + if row["profit"] is not None and row["revenue"] > 0 + else None + ) + return row + + +def months(start, end): + current = start.replace(day=1) + while current <= end: + yield current.strftime("%Y-%m") + current = (current.replace(day=28) + timedelta(days=4)).replace(day=1) + + +def dimensions(labels, product, sale, channel=""): + product_key = str(product.pk) if product else "unmatched" + labels[product_key] = product.name if product else "Товар не сопоставлен" + channel_key = channel or (sale.channel if sale else "") or "Не указан" + customer_id = sale.customer_external_id if sale else "" + customer_name = sale.customer_name if sale else "" + customer_key = ( + f"{sale.source_id}:{customer_id or customer_name}" + if sale and (customer_id or customer_name) + else "unknown" + ) + labels[customer_key] = customer_name or customer_id or "Клиент не указан" + return product_key, channel_key, customer_key + + +def project_costs(costs, project_id): + if project_id is None: + return costs.filter(project__isnull=True, sale_line__sale__project__isnull=True) + return costs.filter( + Q(project_id=project_id) + | Q(project__isnull=True, sale_line__sale__project_id=project_id) + ) + + +def sales_report(start, end, currency, source=None, product_ids=None, project=None): + lower, upper = bounds(start, end) + lines = SaleLine.objects.filter( + sale__counts_as_sale=True, + sale__currency=currency, + sale__occurred_at__gte=lower, + sale__occurred_at__lt=upper, + ).select_related("sale", "sale__project", "product") + costs = ( + Cost.objects.filter( + currency=currency, occurred_at__gte=lower, occurred_at__lt=upper + ) + .select_related( + "product", + "sale_line__product", + "sale_line__sale", + "sale_line__sale__project", + "project", + ) + .exclude(sale_line__sale__counts_as_sale=False) + ) + if source: + 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) + + if product_ids is not None: + lines = lines.filter(product_id__in=product_ids) + costs = costs.filter( + Q(sale_line__product_id__in=product_ids) + | Q(sale_line__isnull=True, product_id__in=product_ids) + ) + + total = bucket() + groups = { + name: defaultdict(bucket) + for name in [ + "products", + "channels", + "customers", + "product_channels", + "monthly", + "projects", + ] + } + for month in months(start, end): + groups["monthly"][month] + labels = {} + unmatched = 0 + missing_customer = 0 + cost_types = defaultdict(lambda: ZERO) + + def targets(product, sale, occurred_at, channel="", project=None): + p, ch, customer = dimensions(labels, product, sale, channel) + project = project or (sale.project if sale else None) + project_key = f"project:{project.pk}" if project else "unassigned" + labels[project_key] = project.name if project else "Без проекта" + month = timezone.localtime(occurred_at).strftime("%Y-%m") + return [ + total, + groups["products"][p], + groups["channels"][ch], + groups["customers"][customer], + groups["product_channels"][(p, ch)], + groups["monthly"][month], + groups["projects"][project_key], + ] + + for line in lines.iterator(chunk_size=1000): + sign = -1 if line.sale.event_type == "return" else 1 + # A zero without explicit provenance is unknown, not free inventory. + missing = line.metadata.get("cost_source") == "unavailable" or ( + line.cost_amount == 0 + and line.metadata.get("cost_source") not in ["manual", "known_zero"] + ) + for row in targets(line.product, line.sale, line.sale.occurred_at): + row["quantity"] += sign * line.quantity + row["revenue"] += sign * (line.gross_amount - line.discount_amount) + row["cost_of_goods"] += sign * line.cost_amount + row["missing_cost_lines"] += int(missing) + row["sale_lines"] += 1 + unmatched += int(line.product_id is None) + missing_customer += int( + dimensions(labels, line.product, line.sale)[2] == "unknown" + ) + + for cost in costs.iterator(chunk_size=1000): + sale = cost.sale_line.sale if cost.sale_line_id else None + product = cost.sale_line.product if cost.sale_line_id else cost.product + # Attribute linked expenses to the same channel as their sale. + for row in targets( + product, sale, cost.occurred_at, "" if sale else cost.channel, cost.project + ): + row["expenses"] += cost.amount + cost_types[cost.cost_type] += cost.amount + + result = { + "summary": finish(total), + "cost_breakdown": dict(cost_types), + "quality": { + "unmatched_product_lines": unmatched, + "missing_customer_lines": missing_customer, + "missing_cost_lines": total["missing_cost_lines"], + }, + } + result.update(grouped_rows(groups, labels)) + return result + + +def grouped_rows(groups, labels): + result = {} + for dimension, rows in groups.items(): + output = [] + for key, row in rows.items(): + item = finish(row) + if dimension == "product_channels": + item.update( + id=f"{key[0]}:{key[1]}", + product_id=key[0], + name=labels[key[0]], + channel=key[1], + ) + else: + item.update(id=key, name=labels.get(key, key)) + output.append(item) + result[dimension] = ( + sorted(output, key=lambda r: r["id"]) + if dimension == "monthly" + else sorted(output, key=lambda r: (-r["revenue"], r["name"], r["id"])) + ) + return result + + +def production_report(start, end, currency, sales, project=None): + entries = ReportingEntry.objects.filter( + date__range=(start, end), voided_at__isnull=True + ).filter(Q(kind="production") | Q(currency=currency)) + if project is not None: + entries = entries.filter( + project_id=None if project == "unassigned" else project.pk + ) + product_rows = { + r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["products"] + } + monthly = { + r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["monthly"] + } + totals = defaultdict(lambda: ZERO) + counts = defaultdict(int) + for entry in entries.select_related("product"): + totals[entry.kind] += ( + entry.quantity if entry.kind == "production" else entry.amount + ) + counts[entry.kind] += 1 + if entry.kind not in ["production", "purchase"]: + continue + field = "produced" if entry.kind == "production" else "purchases" + value = entry.quantity if entry.kind == "production" else entry.amount + month = entry.date.strftime("%Y-%m") + monthly[month][field] += value + key = str(entry.product_id) if entry.product_id else "unallocated" + if key not in product_rows: + product_rows[key] = { + **finish(bucket()), + "id": key, + "name": entry.product.name + if entry.product + else "Закупки без привязки к товару", + "produced": ZERO, + "purchases": ZERO, + } + product_rows[key][field] += value + return { + "products": list(product_rows.values()), + "monthly": list(monthly.values()), + "totals": {kind: totals[kind] for kind in ReportingEntry.Kind.values}, + "record_counts": {kind: counts[kind] for kind in ReportingEntry.Kind.values}, + "product_channels": sales["product_channels"], + } diff --git a/backend/finance/migrations/0032_reportingentry.py b/backend/finance/migrations/0032_reportingentry.py new file mode 100644 index 00000000..3c3b1776 --- /dev/null +++ b/backend/finance/migrations/0032_reportingentry.py @@ -0,0 +1,174 @@ +# Generated by Django 5.2.4 on 2026-09-07 06:31 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commerce", "0012_sale_customer_external_id_sale_customer_name"), + ("finance", "0031_payrolltablereviewmark"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ReportingEntry", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "kind", + models.CharField( + choices=[ + ("production", "Выпуск продукции"), + ("purchase", "Закупка для производства"), + ("tax", "Уплаченные налоги сотрудника"), + ("overhead", "Общие расходы"), + ("cash_in", "Поступление денег"), + ("cash_out", "Выплата денег"), + ], + max_length=16, + ), + ), + ("date", models.DateField(db_index=True, verbose_name="Дата факта")), + ( + "reference", + models.CharField( + max_length=200, verbose_name="Документ / уникальный номер" + ), + ), + ( + "description", + models.CharField( + blank=True, max_length=500, verbose_name="Описание" + ), + ), + ( + "quantity", + models.DecimalField( + decimal_places=4, + default=0, + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + ), + ), + ( + "currency", + models.CharField( + default="RUB", + max_length=3, + validators=[ + django.core.validators.RegexValidator("^[A-Z]{3}$") + ], + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("voided_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="reporting_entries_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "employee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="commerce.product", + ), + ), + ( + "voided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="reporting_entries_voided", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-date", "-pk"], + "constraints": [ + models.UniqueConstraint( + fields=("kind", "reference"), + name="reporting_entry_reference_unique", + ), + models.CheckConstraint( + condition=models.Q(("amount__gte", 0), ("quantity__gte", 0)), + name="reporting_entry_nonnegative", + ), + models.CheckConstraint( + condition=models.Q( + models.Q( + ("amount", 0), + ("employee__isnull", True), + ("kind", "production"), + ("product__isnull", False), + ("quantity__gt", 0), + ), + models.Q( + ("amount__gt", 0), + ("employee__isnull", True), + ("kind", "purchase"), + ), + models.Q( + ("amount__gt", 0), + ("employee__isnull", False), + ("kind", "tax"), + ("product__isnull", True), + ("quantity", 0), + ), + models.Q( + ("amount__gt", 0), + ("employee__isnull", True), + ("kind__in", ["overhead", "cash_in", "cash_out"]), + ("product__isnull", True), + ("quantity", 0), + ), + _connector="OR", + ), + name="reporting_entry_kind_fields", + ), + ], + }, + ), + ] diff --git a/backend/finance/migrations/0033_reportingentry_project.py b/backend/finance/migrations/0033_reportingentry_project.py new file mode 100644 index 00000000..819b0978 --- /dev/null +++ b/backend/finance/migrations/0033_reportingentry_project.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.4 on 2026-09-07 07:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commerce', '0014_externalproject_projectpayment_and_more'), + ('finance', '0032_reportingentry'), + ] + + operations = [ + migrations.AddField( + model_name='reportingentry', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 2022469d..683cf551 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -26,6 +26,7 @@ from .enums import ( PayrollRunStatus, VacationPayInstructionStatus, ) +from .analytics.models import ReportingEntry # noqa: F401 currency_validator = RegexValidator( regex=r"^[A-Z]{3}$", diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index 1ab94537..465c5983 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -442,16 +442,17 @@ class TestSearchAPI: assert employees assert employees[0]["image"].endswith("/users/avatars/search-test.jpg") - def test_search_includes_products(self, api_client): - """Глобальный поиск охватывает каталог товаров.""" - product = Product.objects.create( + @pytest.mark.parametrize("model_type", ["", "product"]) + def test_search_hides_deferred_products(self, api_client, model_type): + """Отложенный модуль не раскрывается через поиск и его счётчики.""" + Product.objects.create( sku="SEARCH-SKU-001", name="Уникальный поисковый товар", barcode="4600000000001", ) call_command("buildwatson", verbosity=0) - response = api_client.get("/api/v1/search/?q=товар") + response = api_client.get("/api/v1/search/", {"q": "товар", "type": model_type}) assert response.status_code == status.HTTP_200_OK products = [ @@ -459,10 +460,10 @@ class TestSearchAPI: for item in response.data["results"] if item["model_name"] == "product" ] - matching_product = next( - item for item in products if item["object_id"] == str(product.id) - ) - assert matching_product["meta"]["sku"] == "SEARCH-SKU-001" + assert products == [] + assert "product" not in response.data["counts"] + assert "product" not in response.data["hidden_counts"] + assert response.data["total"] == 0 def test_search_counts_inaccessible_task_boards( self, api_client, regular_user, make_user diff --git a/backend/tests/commerce/test_admin.py b/backend/tests/commerce/test_admin.py new file mode 100644 index 00000000..5df407a6 --- /dev/null +++ b/backend/tests/commerce/test_admin.py @@ -0,0 +1,137 @@ +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import Permission +from django.urls import reverse + +from commerce.integrations.moysklad import MoySkladError +from commerce.models import IntegrationConnection, IntegrationSyncRun, SourceSystem +from commerce.services.credentials import decrypt_credentials, encrypt_credentials + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def admin_client(client, user_factory): + client.force_login(user_factory(staff=True, superuser=True)) + return client + + +@pytest.fixture +def connection(): + source = SourceSystem.objects.create(code="moysklad", name="МойСклад") + return IntegrationConnection.objects.create( + source=source, provider="moysklad", auth_type="token", status="ready", + encrypted_credentials=encrypt_credentials({"token": "existing-test-token"}), + ) + + +def connection_data(**overrides): + return {"provider": "moysklad", "auth_type": "token", "auto_sync": "on", + "sync_interval_minutes": "15", **overrides} + + +def action(client, connection, name): + return client.post(reverse("admin:commerce_integrationconnection_changelist"), { + "action": name, "_selected_action": [str(connection.pk)], + }) + + +def test_admin_creates_encrypted_connection_and_preserves_blank_credentials(admin_client): + response = admin_client.post( + reverse("admin:commerce_integrationconnection_add"), + connection_data(token="admin-test-token"), + ) + assert response.status_code == 302 + connection = IntegrationConnection.objects.get() + assert connection.source.code == "moysklad" + assert connection.created_by.is_superuser + assert "admin-test-token" not in connection.encrypted_credentials + assert decrypt_credentials(connection) == {"token": "admin-test-token"} + encrypted = connection.encrypted_credentials + url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk]) + page = admin_client.get(url) + assert page.status_code == 200 + assert b"admin-test-token" not in page.content + assert encrypted.encode() not in page.content + response = admin_client.post(url, connection_data(sync_interval_minutes="30")) + assert response.status_code == 302 + connection.refresh_from_db() + assert connection.encrypted_credentials == encrypted + assert connection.sync_interval_minutes == 30 + assert connection.next_sync_at is not None + + +def test_admin_requires_complete_new_auth_and_preserves_password_spaces(admin_client, connection): + url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk]) + response = admin_client.post(url, connection_data(auth_type="basic")) + assert response.status_code == 200 + assert {"login", "password"} <= response.context["adminform"].form.errors.keys() + connection.refresh_from_db() + assert connection.auth_type == "token" + response = admin_client.post(url, connection_data( + auth_type="basic", login="admin@test", password=" password with spaces ", + )) + assert response.status_code == 302 + connection.refresh_from_db() + assert decrypt_credentials(connection) == {"login": "admin@test", "password": " password with spaces "} + assert connection.status == "disconnected" + + +def test_admin_rejects_basic_auth_for_wildberries(admin_client): + response = admin_client.post(reverse("admin:commerce_integrationconnection_add"), + connection_data(provider="wildberries", auth_type="basic", login="test", password="test")) + assert response.status_code == 200 + assert "auth_type" in response.context["adminform"].form.errors + assert not IntegrationConnection.objects.exists() + + +def test_admin_test_action_updates_state_and_reports_failure(admin_client, connection): + with patch("commerce.admin.test_moysklad_connection", return_value={ + "account_name": "Test company", "account_id": "test-account", + }): + assert action(admin_client, connection, "test_connections").status_code == 302 + connection.refresh_from_db() + assert connection.status == "ready" + assert connection.account_name == "Test company" + assert connection.last_tested_at is not None + assert connection.source.metadata["account_id"] == "test-account" + with patch("commerce.admin.test_moysklad_connection", side_effect=MoySkladError("Test failure")): + assert action(admin_client, connection, "test_connections").status_code == 302 + connection.refresh_from_db() + assert connection.status == "error" + assert connection.last_error == "Test failure" + + +def test_admin_sync_enqueues_once_and_blocks_editing_active_connection( + admin_client, connection, django_capture_on_commit_callbacks, +): + with patch("commerce.tasks.sync_connection_run.apply_async") as publish, \ + django_capture_on_commit_callbacks(execute=True): + assert action(admin_client, connection, "sync_connections").status_code == 302 + assert action(admin_client, connection, "sync_connections").status_code == 302 + publish.assert_called_once() + assert IntegrationSyncRun.objects.filter(connection=connection).count() == 1 + connection.refresh_from_db() + assert connection.status == "queued" + url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk]) + response = admin_client.post(url, connection_data(token="replacement")) + assert response.status_code == 200 + assert response.context["adminform"].form.non_field_errors() + connection.refresh_from_db() + assert decrypt_credentials(connection) == {"token": "existing-test-token"} + + +def test_view_only_staff_cannot_run_admin_actions(client, user_factory, connection): + user = user_factory(staff=True) + user.user_permissions.add(Permission.objects.get( + content_type__app_label="commerce", codename="view_integrationconnection", + )) + client.force_login(user) + assert client.get(reverse( + "admin:commerce_integrationconnection_change", args=[connection.pk], + )).status_code == 200 + with patch("commerce.admin.enqueue_connection_sync") as enqueue: + action(client, connection, "sync_connections") + enqueue.assert_not_called() + assert not IntegrationSyncRun.objects.exists() diff --git a/backend/tests/finance/test_analytics.py b/backend/tests/finance/test_analytics.py new file mode 100644 index 00000000..5d4cf96d --- /dev/null +++ b/backend/tests/finance/test_analytics.py @@ -0,0 +1,327 @@ +from datetime import date, datetime +from decimal import Decimal + +import pytest +from django.urls import reverse +from django.utils import timezone + +from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem +from finance.analytics.models import ReportingEntry +from finance.analytics.people import people_report +from finance.analytics.sales import production_report, sales_report +from finance.models import FinanceSettings, PayrollPeriod, PayrollRun, PayrollStatement + +pytestmark = pytest.mark.django_db + + +def moment(day): + return timezone.make_aware(datetime.fromisoformat(day + "T12:00:00")) + + +@pytest.fixture +def facts(): + source = SourceSystem.objects.create(code="test-analytics", name="Test") + product = Product.objects.create(sku="ANALYTICS", name="Test product") + + def line(ref, revenue="100", cost="40", day="2026-01-10", **kwargs): + sale = Sale.objects.create( + source=source, + external_id=ref, + occurred_at=moment(day), + channel="direct", + customer_external_id="client-1", + customer_name="Client", + **kwargs, + ) + return SaleLine.objects.create( + sale=sale, + product=product, + external_id="1", + quantity=1, + gross_amount=Decimal(revenue), + cost_amount=Decimal(cost), + ) + + return source, product, line + + +def test_sales_reconcile_refunds_expenses_currency_and_date_boundaries(facts): + source, product, line = facts + sale_line = line("sale") + line("return", revenue="25", cost="10", event_type="return") + line("cancelled", counts_as_sale=False) + line("usd", currency="USD") + line("outside", day="2026-02-01") + Cost.objects.create( + source=source, + product=product, + sale_line=sale_line, + external_id="fee", + amount=5, + cost_type="commission", + occurred_at=moment("2026-01-31"), + ) + Cost.objects.create( + source=source, + product=product, + amount=2, + cost_type="advertising", + occurred_at=moment("2026-01-15"), + ) + report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB") + assert report["summary"]["revenue"] == 75 + assert report["summary"]["cost_of_goods"] == 30 + assert report["summary"]["profit"] == 38 + assert report["summary"]["expenses"] == 7 + for dimension in [ + "products", + "channels", + "customers", + "product_channels", + "monthly", + ]: + assert sum(row["profit"] for row in report[dimension]) == 38 + assert next(r for r in report["customers"] if r["id"] == "unknown")["expenses"] == 2 + assert report["quality"]["missing_customer_lines"] == 0 + + +def test_unknown_cost_is_not_reported_as_profit_and_source_filter(facts): + source, _, line = facts + line("missing", cost="0") + report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", source) + assert report["summary"]["profit"] is None + assert report["summary"]["missing_cost_lines"] == 1 + other = SourceSystem.objects.create(code="empty", name="Empty") + assert ( + sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", other)["summary"][ + "revenue" + ] + == 0 + ) + + +def test_report_api_auth_allowlist_validation_and_no_store(api_client, user_factory): + url = reverse("api:v1:finance-payroll:analytics-report") + assert api_client.get(url).status_code == 401 + api_client.force_authenticate(user=user_factory()) + assert api_client.get(url).status_code == 403 + staff = user_factory(staff=True) + api_client.force_authenticate(user=staff) + params = {"date_from": "2026-01-01", "date_to": "2026-01-31", "currency": "RUB"} + response = api_client.get(url, params) + assert response.status_code == 200 + assert response.data["sales"]["summary"]["revenue"] == "0.0000" + assert "no-store" in response["Cache-Control"] + for changes in [ + {"date_from": "bad"}, + {"date_to": "2025-01-01"}, + {"date_to": "2028-01-01"}, + {"currency": "rub"}, + {"section": "bad"}, + {"source": "nope"}, + ]: + assert api_client.get(url, {**params, **changes}).status_code == 400 + FinanceSettings.get_solo().management_users.add(user_factory(staff=True)) + assert api_client.get(url, params).status_code == 403 + + +def test_journal_validates_duplicates_and_void_preserves_history( + api_client, user_factory, facts +): + _, product, _ = facts + staff = user_factory(staff=True) + api_client.force_authenticate(user=staff) + url = reverse("api:v1:finance-payroll:analytics-entries") + payload = { + "kind": "production", + "reference": "output-1", + "date": "2026-01-10", + "quantity": "3", + "product": str(product.pk), + } + for change in [ + {"product": None}, + {"quantity": "-1"}, + {"amount": "10"}, + {"date": "2099-01-01"}, + ]: + assert ( + api_client.post(url, {**payload, **change}, format="json").status_code + == 400 + ) + response = api_client.post(url, payload, format="json") + assert response.status_code == 201, response.data + assert api_client.post(url, payload, format="json").status_code == 400 + start, end = date(2026, 1, 1), date(2026, 1, 31) + sales = sales_report(start, end, "RUB") + assert production_report(start, end, "RUB", sales)["totals"]["production"] == 3 + void_url = reverse( + "api:v1:finance-payroll:analytics-entry-void", args=[response.data["id"]] + ) + assert api_client.post(void_url).status_code == 200 + assert api_client.post(void_url).status_code == 200 + assert ReportingEntry.objects.count() == 1 + assert production_report(start, end, "RUB", sales)["totals"]["production"] == 0 + + +def test_payroll_uses_published_revision_month_sums_and_actual_tax(user_factory): + author, approver, employee = user_factory(), user_factory(), user_factory() + for index, (start, end, gross) in enumerate( + [ + ("2026-01-01", "2026-01-15", 40), + ("2026-01-16", "2026-01-31", 60), + ("2026-02-01", "2026-02-28", 120), + ] + ): + period = PayrollPeriod.objects.create( + code=f"a-{index}", date_from=start, date_to=end, currency="RUB" + ) + run = PayrollRun.objects.create( + period=period, + revision=1, + status="published", + requested_by=author, + approved_by=approver, + approved_at=timezone.now(), + published_by=approver, + published_at=timezone.now(), + gross_total=gross, + deduction_total=0, + payable_total=gross, + ) + PayrollStatement.objects.create( + run=run, + employee=employee, + currency="RUB", + gross_before_adjustments=gross, + adjustment_total=0, + gross_total=gross, + deduction_total=0, + net_pay=gross, + payment_total=0, + payable=gross, + ) + # A new draft must not hide or double-count the published run. + PayrollRun.objects.create( + period=period, + revision=2, + requested_by=author, + gross_total=999, + deduction_total=0, + payable_total=999, + ) + ReportingEntry.objects.create( + kind="tax", + reference="tax-1", + date=date(2026, 2, 10), + employee=employee, + amount=13, + created_by=author, + ) + report = people_report(date(2026, 1, 1), date(2026, 2, 28), "RUB") + row = next(r for r in report["employees"] if r["id"] == employee.pk) + assert row["salary_total"] == 220 + assert row["average_salary"] == 110 + assert row["salary_growth_percent"] == 20 + assert row["tax_paid"] == 13 + assert row["attendance_days"] == 0 + partial = people_report(date(2026, 1, 10), date(2026, 1, 31), "RUB") + assert ( + next(r for r in partial["employees"] if r["id"] == employee.pk)["salary_total"] + == 60 + ) + + +def test_personnel_history_leave_overlap_missing_tax_and_future_hire(user_factory): + from employees.models import EmployeeAction + + employee = user_factory() + future = user_factory() + EmployeeAction.objects.bulk_create( + [ + EmployeeAction( + employee=employee, action="hired", date=moment("2025-01-01") + ), + EmployeeAction( + employee=employee, + action="on_leave", + date=moment("2026-01-10"), + date_to=date(2026, 1, 20), + ), + EmployeeAction( + employee=employee, + action="on_sick_leave", + date=moment("2026-01-15"), + date_to=date(2026, 1, 17), + ), + EmployeeAction(employee=future, action="hired", date=moment("2026-03-01")), + ] + ) + report = people_report(date(2026, 1, 1), date(2026, 1, 31), "RUB") + row = next(r for r in report["employees"] if r["id"] == employee.pk) + assert row["vacation_days"] == 8 + assert row["sick_days"] == 3 + assert row["tenure_days"] == 395 + assert all(r["id"] != future.pk for r in report["employees"]) + assert report["summary"]["tax_paid"] is None + assert report["summary"]["average_salary"] is None + + +def test_customer_backfill_extracts_only_counterparty_and_is_idempotent(facts): + from importlib import import_module + from types import SimpleNamespace + from django.apps import apps + from django.db import connection + from commerce.models import SourceRecord + + source, _, line = facts + record = SourceRecord.objects.create( + source=source, + entity_type="demand", + external_id="backfill", + payload={ + "agent": { + "meta": {"href": "https://example.test/counterparty/client-id"}, + "name": "Customer", + } + }, + ) + sale = line("backfill").sale + sale.source_record = record + sale.customer_external_id = "" + sale.customer_name = "" + sale.save() + restore = import_module( + "commerce.migrations.0013_restore_sale_customers" + ).restore_customers + restore(apps, SimpleNamespace(connection=connection)) + restore(apps, SimpleNamespace(connection=connection)) + sale.refresh_from_db() + assert sale.customer_external_id == "client-id" + assert sale.customer_name == "Customer" + + +def test_production_api_excludes_resold_products(api_client, user_factory, facts): + _, product, line = facts + staff = user_factory(staff=True) + api_client.force_authenticate(user=staff) + line("manufactured") + resold = Product.objects.create(sku="RESOLD", name="Resold") + other = line("resold", revenue="999", cost="1") + other.product = resold + other.save() + ReportingEntry.objects.create( + kind="production", + reference="previous-year", + date=date(2025, 12, 1), + product=product, + quantity=10, + created_by=staff, + ) + response = api_client.get( + reverse("api:v1:finance-payroll:analytics-report"), + {"section": "production", "date_from": "2026-01-01", "date_to": "2026-01-31"}, + ) + assert response.status_code == 200, response.data + assert response.data["sales"]["summary"]["revenue"] == "100.0000" + assert response.data["production"]["record_counts"]["production"] == 0 diff --git a/backend/tests/finance/test_projects.py b/backend/tests/finance/test_projects.py new file mode 100644 index 00000000..b7f10100 --- /dev/null +++ b/backend/tests/finance/test_projects.py @@ -0,0 +1,300 @@ +from datetime import date +from decimal import Decimal +from unittest.mock import Mock +import httpx + +import pytest +from django.urls import reverse +from django.utils import timezone + +from commerce.integrations.moysklad import MoySkladClient, MoySkladError +from commerce.models import ( + ExternalProject, + ProjectPayment, + Sale, + SaleLine, + Cost, + SourceSystem, +) +from commerce.services.moysklad_projects import ( + normalize_project, + normalize_project_payment, + sync_project_payments, + sync_project_catalog, + restore_sale_projects, +) +from commerce.services.ingestion import ingest_source_record +from finance.analytics.projects import projects_report +from finance.analytics.sales import sales_report +from finance.analytics.models import ReportingEntry + +pytestmark = pytest.mark.django_db +START, END = date(2026, 1, 1), date(2026, 1, 31) + + +@pytest.fixture +def source(): + return SourceSystem.objects.create(code="projects-test", name="МойСклад") + + +def payment(source, ref="payment1", **kwargs): + row = { + "id": ref, + "moment": "2026-01-10 12:00:00", + "sum": 10000, + "applicable": True, + "name": "Оплата", + "project": {"id": "project1", "name": "Первый"}, + "expenseItem": {"name": "Материалы"}, + } + row.update(kwargs) + return normalize_project_payment(source, row, "paymentout", timezone.now()) + + +def test_project_payment_reimport_reassignment_and_cancel(source): + p = payment(source) + assert p.amount == Decimal("100") + assert payment(source).pk == p.pk + assert ProjectPayment.objects.count() == 1 + payment(source, project={"id": "project2", "name": "Второй"}, sum=15000) + p.refresh_from_db() + assert p.project.external_id == "project2" and p.amount == 150 + payment(source, project=None, applicable=False) + p.refresh_from_db() + assert p.project is None and not p.applicable + assert p.source_record.payload["applicable"] is False + + +def test_full_scan_marks_missing_but_failed_scan_preserves_history(source): + old = payment(source) + client = Mock() + client.iter_project_data.side_effect = MoySkladError("offline") + with pytest.raises(MoySkladError): + sync_project_payments(source, client) + old.refresh_from_db() + assert not old.missing_from_source + client.iter_project_data.side_effect = lambda *a, **kw: iter([]) + assert sync_project_payments(source, client) == 0 + old.refresh_from_db() + assert old.missing_from_source + payment(source) + old.refresh_from_db() + assert not old.missing_from_source + + +def test_catalog_preserves_archive_and_source_identity(source): + client = Mock() + client.iter_project_data.side_effect = lambda kind, archived=False: iter( + [ + { + "id": "archived" if archived else "active", + "name": "Одинаковое имя", + "archived": archived, + } + ] + ) + assert sync_project_catalog(source, client) == 2 + assert sync_project_catalog(source, client) == 2 + assert ExternalProject.objects.count() == 2 + other = SourceSystem.objects.create(code="other", name="Other") + normalize_project(other, {"id": "active", "name": "Other account"}) + assert ExternalProject.objects.count() == 3 + assert ExternalProject.objects.get(source=source, external_id="archived").archived + + +def test_project_report_reconciles_currency_dates_unassigned_and_manual( + source, user_factory +): + paid = payment(source) + project = paid.project + payment(source, ref="unassigned", project=None, sum=5000) + payment(source, ref="cancelled", applicable=False) + payment(source, ref="outside", moment="2026-02-01 12:00:00") + payment(source, ref="foreign", rate={"currency": {"isoCode": "USD"}}) + sale = Sale.objects.create( + source=source, project=project, external_id="sale", occurred_at=paid.occurred_at + ) + line = SaleLine.objects.create( + sale=sale, external_id="1", gross_amount=500, cost_amount=200, quantity=1 + ) + Cost.objects.create( + source=source, + sale_line=line, + cost_type="delivery", + amount=20, + occurred_at=paid.occurred_at, + ) + Cost.objects.create( + source=source, + project=project, + cost_type="other", + amount=30, + occurred_at=paid.occurred_at, + ) + user = user_factory() + ReportingEntry.objects.create( + kind="overhead", + project=project, + date=START, + reference="overhead", + amount=40, + created_by=user, + ) + ReportingEntry.objects.create( + kind="cash_out", + project=project, + date=START, + reference="manual", + amount=10, + created_by=user, + ) + sales = sales_report(START, END, "RUB") + report = projects_report(START, END, "RUB", sales) + assert report["summary"]["paid"] == 150 + assert report["summary"]["unassigned_paid"] == 50 + assert ( + sum(r["paid"] for r in report["rows"]) + == sum(r["paid"] for r in report["categories"]) + == 150 + ) + assert sum(r["paid"] for r in report["monthly"]) == 150 + scoped_sales = sales_report(START, END, "RUB", project=project) + assert ( + scoped_sales["summary"]["profit"] == 250 + ) # cash payments are not expensed twice + scoped = projects_report(START, END, "RUB", scoped_sales, project=project) + 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"] + == 0 + ) + + +def test_projects_api_permissions_filter_and_journal(api_client, user_factory, source): + p = payment(source) + url = reverse("api:v1:finance-payroll:analytics-report") + payments_url = reverse("api:v1:finance-payroll:analytics-project-payments") + 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)) + params = { + "date_from": str(START), + "date_to": str(END), + "project": p.project_id, + "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"] + 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 api_client.post(payments_url, {}).status_code == 405 + entry_url = reverse("api:v1:finance-payroll:analytics-entries") + assert ( + api_client.post( + entry_url, + { + "kind": "overhead", + "project": p.project_id, + "date": "2026-01-01", + "reference": "API", + "amount": "5.00", + }, + format="json", + ).status_code + == 201 + ) + assert api_client.get(entry_url, params).data["count"] == 1 + assert ( + api_client.get(entry_url, {**params, "project": "unassigned"}).data["count"] + == 0 + ) + + +def test_saved_sale_project_backfill_is_idempotent(source): + record = ingest_source_record( + source=source, + entity_type="demand", + external_id="old", + payload={ + "project": { + "meta": { + "href": "https://api.moysklad.ru/api/remap/1.2/entity/project/project1" + } + } + }, + ).record + sale = Sale.objects.create( + source=source, + source_record=record, + external_id="old", + occurred_at=timezone.now(), + ) + assert restore_sale_projects(source) == 1 + assert restore_sale_projects(source) == 0 + sale.refresh_from_db() + assert sale.project.external_id == "project1" + + +def test_project_client_pagination_and_archive_filters(): + client = MoySkladClient(auth_type="token", credentials={"token": "test"}) + client._get = Mock(side_effect=[{"rows": [{"id": "1"}]}, {"rows": []}]) + assert list(client.iter_project_data("project", archived=True, limit=1)) == [ + {"id": "1"} + ] + assert client._get.call_args_list[0].kwargs["params"]["filter"] == "archived=true" + assert client._get.call_args_list[1].kwargs["params"]["offset"] == 1 + client._get = Mock(return_value={"rows": None}) + with pytest.raises(MoySkladError): + list(client.iter_project_data("paymentout")) + client.close() + + +def test_transient_http_failure_retries_without_replaying_writes(monkeypatch): + monkeypatch.setattr("commerce.integrations.moysklad.time.sleep", lambda _: None) + client = MoySkladClient(auth_type="token", credentials={"token": "test"}) + client.client.close() + responses = iter([502, 429, 200]) + client.client = httpx.Client( + base_url=client.base_url, + transport=httpx.MockTransport( + lambda request: httpx.Response( + next(responses), json={"rows": []}, request=request + ) + ), + ) + assert list(client.iter_project_data("project")) == [] + client.close() + + +def test_payment_references_are_cached_and_paths_stay_on_api(): + client = MoySkladClient(auth_type="token", credentials={"token": "test"}) + external_id = "11111111-1111-1111-1111-111111111111" + reference = { + "meta": { + "type": "expenseitem", + "href": f"https://api.moysklad.ru/api/remap/1.2/entity/expenseitem/{external_id}", + } + } + client._get = Mock(return_value={"id": external_id, "name": "Доставка"}) + for _ in range(3): + assert ( + client._payment_references({"expenseItem": reference})["expenseItem"][ + "name" + ] + == "Доставка" + ) + client._get.assert_called_once_with(f"/entity/expenseitem/{external_id}") + with pytest.raises(MoySkladError): + client._payment_reference( + {"meta": {"type": "../private", "href": "https://invalid/secret"}} + ) + client.close() diff --git a/docs/README.md b/docs/README.md index 1d1d855f..abad1e9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ - [Scheduling module](../backend/scheduling/README.md) - [Procurement module](../backend/procurement/README.md) - [Finance module](../backend/finance/README.md) +- [Аналитика компании](guides/COMPANY_ANALYTICS.md) - [Payroll core](../backend/payroll_core/README.md) Интерактивные runtime-контракты: diff --git a/docs/guides/COMMERCE.md b/docs/guides/COMMERCE.md index 29c12ee4..f520bb08 100644 --- a/docs/guides/COMMERCE.md +++ b/docs/guides/COMMERCE.md @@ -1,5 +1,8 @@ # Commerce, МойСклад и маркетплейсы +Сводные отчёты продаж, сотрудников и производства описаны в +[руководстве по аналитике компании](COMPANY_ANALYTICS.md). + Commerce-контур хранит нормализованные товары, внешние привязки, продажи, возвраты, расходы, остатки и локальные изображения товаров. Источником истины для структуры данных служат модели `backend/commerce/models/`, для HTTP API — @@ -8,6 +11,26 @@ Commerce-контур хранит нормализованные товары, ## Импорт +Пользовательский раздел «Товары» временно скрыт для дальнейшей разработки: +пункт меню и выдача товаров в глобальном поиске отключены, адреса `/products` +и вложенные страницы перенаправляют в ленту. Код экранов, данные и HTTP API +сохранены; финансовая аналитика продолжает использовать импортированные факты. + +Управление доступно в Django admin → «Подключения интеграций» +(`/admin/commerce/integrationconnection/`). Здесь можно добавить поддерживаемый +сервис, заменить реквизиты, настроить автосинхронизацию и интервал. Пустые поля +реквизитов сохраняют прежние значения; сохранённые секреты не показываются. +Для проверки и ручного импорта отметьте подключение в списке и выберите +«Проверить выбранные подключения» или «Синхронизировать выбранные подключения». +Эти действия требуют права изменения подключений. Повторный импорт во время +активного запуска не создаёт новую задачу. + +Прогресс и ошибки доступны в «Запусках синхронизации» +(`/admin/commerce/integrationsyncrun/`). Проекты и выплаты по проектам также +доступны в админке для просмотра; исправления импортированных документов +вносятся в сервисе-источнике. Новые провайдеры требуют реализации коннектора; +добавление одной записи источника не создаёт интеграцию автоматически. + Синхронизация любого подключения запускается Celery-задачей `commerce.sync_connection_run`; сервис выбирается по `connection.provider`. Задача публикуется только после commit транзакции, а заранее сформированный diff --git a/docs/guides/COMPANY_ANALYTICS.md b/docs/guides/COMPANY_ANALYTICS.md new file mode 100644 index 00000000..147a7eb9 --- /dev/null +++ b/docs/guides/COMPANY_ANALYTICS.md @@ -0,0 +1,216 @@ +# Аналитика компании + +Аналитика встроена во вкладку «Аналитика компании» на странице `/finances`. +Внутренние вкладки: проекты, продажи, сотрудники, производство и журнал фактов. +Выбранные вкладки запоминаются для пользователя; обычный и широкий режимы +используют то же переключение, что и управление зарплатой. Фильтры открываются модальным окном по кнопке в шапке финансов; +под внутренними вкладками остаётся короткая строка применённых фильтров. +Отмена закрывает окно без изменения отчёта. Карточки адаптируются к ширине +рабочей области. В обычном режиме и при ширине рабочей области меньше 928px +списки по умолчанию свёрнуты до заголовка со счётчиком и стрелкой. +После открытия списка табличные строки представлены карточками с раскрывающимися +подробностями. В широком режиме списки всегда открыты и показывают таблицы. +Это относится к сводным отчётам, документам выплат и журналу фактов. +В широком режиме при ширине рабочей области от 928px основной блок сочетает +график и таблицу в двух колонках, если таблица помещается без горизонтальной +прокрутки. Иначе график располагается сверху, а таблица — снизу на всю ширину. +Компоновка пересчитывается при изменении размера области и данных таблицы. +Дополнительные блоки располагаются попарно. +Таблицы имеют собственную прокрутку и закреплённые заголовки. +В обычном и узком режиме графики открываются только модальным окном по кнопке +в заголовке списка, в том числе когда список свёрнут. + +У каждого списка есть график с выбором показателя: разные единицы не смешиваются. +Месячные данные представлены линией с разрывами на неизвестных значениях; +остальные — сравнением до семи позиций. Высота области всех графиков — 280px. +Для явно отмеченных суммируемых показателей при числе позиций больше семи +показываются шесть крупнейших по модулю и «Прочее» — сумма остальных с учётом +знака. Число объединённых позиций видно в подписи и подсказке. Неизвестные +значения исключаются и отмечаются отдельно. Проценты, средние, возраст и стаж +не суммируются; для них показываются семь крупнейших значений. +Месячные ряды сохраняют все месяцы. Полные списки и CSV не сокращаются. +Графики сводных списков учитывают поиск по всем страницам результата. +Для документов и журнала используется только текущая загруженная страница, +что явно подписано над графиком; аннулированные записи журнала исключены. +Поиск, страницы, CSV и права на действия сохраняются в обоих режимах. Методика и инструкции открываются по вопросику в шапке, +пояснения к отдельным таблицам — по вопросику рядом с названием. Предупреждения +о качестве данных остаются в отчётах. Старый адрес `/finances/analytics` +перенаправляет на `/finances?tab=analytics`, открывая встроенную вкладку. +Используются визуальные компоненты и темы портала. Изображения из локальных +материалов служат примерами; их показатели, числа и оформление не являются ТЗ. + +## Возможности + +- Продажи по товарам, каналам, сочетанию товара и канала, клиентам и месяцам. +- Выручка, себестоимость, привязанные расходы, прибыль по учтённым расходам и маржа. +- Зарплата каждого сотрудника, изменение зарплаты, дни работы, отпуска и больничного, + уплаченные налоги, возраст, стаж и динамика численности команды. +- Выпуск, продажи произведённых товаров, производственные закупки и графики + с переключением между закупками, выручкой и прибылью. Прибыль по товарам и каналам повторно доступна + в производственном отчёте и рассчитывается тем же сервисом. +- Журнал подтверждённых фактов: выпуск, производственные закупки, уплаченные налоги + сотрудника, общие расходы, поступления и выплаты денег. +- Выбор дат включительно, валюты и источника продаж. Поиск, страницы таблиц и CSV + всех строк текущей группировки с учётом поиска. Диапазон ограничен 367 днями. + +## Источники и методика + +Продажи и возвраты берутся из `commerce.Sale` и `SaleLine`, расходы — из `Cost`. +Операции с `counts_as_sale=False` и связанные с ними расходы исключаются. +Возвраты уменьшают количество, выручку и себестоимость. Расходы учитываются по +собственной дате; их знак сохраняется, в том числе для компенсаций. + +Выручка равна сумме после скидок и возвратов в денежной базе источника. Сервис не +пересчитывает НДС. Прибыль по учтённым расходам равна выручке минус себестоимость +минус привязанные расходы. Общие расходы и налоги не распределяются автоматически. +Этот показатель не следует принимать за окончательную чистую прибыль компании. +Правила распределения общехозяйственных расходов и налогов требуют согласования. + +Себестоимость с `metadata.cost_source="unavailable"` считается неизвестной. +Нулевая себестоимость без подтверждения `manual` или `known_zero` также считается +неизвестной. Для затронутых групп прибыль и маржа возвращаются `null`, в UI — «—». +API дополнительно возвращает арифметический результат по известным расходам +`contribution_profit`, но интерфейс не показывает его как подтверждённую прибыль. + +Клиенты имеют внешний ID и имя на продаже. Миграция восстанавливает их из +сохранённого `SourceRecord.payload.agent`, если информация присутствует. Новая +синхронизация МойСклад запрашивает контрагента вместе с документом. ID клиента +дополняется ID источника, поэтому одинаковые внешние ID разных систем не +объединяются. Когда имеется только имя, используется имя внутри источника; +для надёжного сопоставления необходимо заполнить внешний ID. +Расходы без привязки к продаже остаются в группе неизвестного клиента. + +Валюты не суммируются и автоматически не конвертируются. Продажи разных систем +не дедуплицируются по предполагаемым совпадениям: при дублирующем импорте одного +оборота следует выбрать один источник. + +Зарплата берётся из опубликованного расчёта каждого периода. Новая черновая +ревизия не заменяет опубликованную. Учитываются только периоды, целиком входящие +в выбранные даты. Начисления нескольких периодов одного месяца складываются по +месяцу окончания расчётного периода. Средняя зарплата сотрудника — начисленная +сумма до удержаний, делённая на число месяцев с расчётами. Среднее компании — по +парам «сотрудник / месяц» с расчётами. Неопубликованные и отсутствующие расчёты +не заменяются нулями. Рост сравнивает первый и последний месяц с опубликованными расчётами внутри +диапазона. Границы сравнения едины для всех сотрудников и показаны в интерфейсе. +Сотрудник включается в сравнение только при наличии обоих расчётов и ненулевой +начальной зарплате. + +Рабочие дни — записи успешного анализа посещаемости с положительными часами и +без признака отсутствия. Отпуска и больничные — календарные дни по кадровым +событиям с существующим приоритетом пересекающихся событий. Стаж учитывает +увольнения и повторный приём, а не дату создания учётной записи. При отсутствии +кадровой истории текущая численность использует активность учётной записи; +историческая динамика включает только известную кадровую историю. Неизвестные +даты рождения и найма исключаются из средних и учитываются в счётчиках пропусков. + +Налоги берутся только из фактически оплаченных записей журнала. Удержания +расчётного листка не означают уплаты налога. Производственный отчёт включает +продажи товаров с неаннулированным фактом выпуска не позже конца периода. +Количество выпуска вводится в единицах соответствующего товара и не зависит от выбранной валюты. Закупка может +относиться к конкретному конечному товару либо оставаться общей. Производственные +закупки не вычитаются повторно из прибыли поверх себестоимости продаж. + +Фильтр источника влияет на продажи и связанные расходы; журнал остаётся общим. +Денежный поток считается только по поступлениям и выплатам из журнала. Нулевое +число записей не подтверждает отсутствие фактических выплат, налогов или выпуска. + +## Доступ и журнал + +Все endpoints требуют авторизации и существующего доступа к управлению финансами, +включая allow-list `FinanceSettings.management_users`. Ответы имеют `private, +no-store`; фронтовое скрытие ссылки не подменяет серверную проверку. + +Для добавления фактов нужно `finance.add_reportingentry`, для аннулирования — +`finance.change_reportingentry`. Действует существующий временный административный +доступ `SIMPLE_ADMIN_ACCESS`. Права на просмотр финансов сами по себе не дают права +изменять журнал. + +Номер документа уникален внутри вида записи. Записи не редактируются и не удаляются: +исправление выполняется аннулированием и новой записью с новым номером. Автор и дата +создания, автор и дата аннулирования сохраняются. Повторное аннулирование идемпотентно. +Будущие факты, отрицательные суммы и несовместимые с видом записи поля отклоняются. + +## API + +- `GET /api/v1/finance/analytics/report/?section=sales|people|production` +- `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 товаров и сотрудников по строке поиска. +CSV экранирует кавычки, переносы строк и потенциальные формулы в текстовых полях. + +## Подключение и проверка + +Перед запуском обновлённого backend выполните `python manage.py migrate`: +миграции Commerce 0012–0013 и Finance 0032 добавляют клиента продажи и журнал фактов. +Для старых документов без имени клиента может потребоваться повторная синхронизация. + +```bash +cd backend +pytest tests/finance/test_analytics.py tests/finance/test_projects.py tests/commerce -q +python manage.py makemigrations --check --dry-run +python manage.py check +``` + +```bash +cd frontend +node --import tsx --test src/lib/analytics.test.js +npx tsc --noEmit +``` + +Исходная книга учёта оплат автоматически не импортируется: в ней есть внешние +ссылки с ошибками и лист с учётными данными. Импорт этой книги требует отдельного +сопоставления колонок, подтверждения исходных сумм и подготовки безопасной копии. +Фраза «всё из годового финансового отчёта» требует отдельного перечня показателей +или самого годового отчёта: книга оплат не задаёт полный состав такого отчёта. +Индекс здоровья, планы и прогнозы из примерных изображений не включены в объём +реализации текстового ТЗ. + +## Проекты МойСклад + +Раздел «Проекты» показывает оплаченные расходы по проектам и статьям, документы +платежей, ручные выплаты, закупки и общие расходы из журнала, а также продажи проекта. +Фильтр проекта работает в продажах, производстве, проектах и журнале. Кадровые +показатели не распределяются по проектам. Вариант «Без проекта» позволяет найти +документы, для которых привязка не заполнена. + +Синхронизация получает `/entity/project` (включая архив), `/entity/paymentout` +и `/entity/cashout` с проектом, статьёй расходов, получателем и валютой. Продажи +получают связь `project` из отгрузки или возврата. Для старых продаж связь +восстанавливается из сохранённого исходного документа, если поле присутствует. +Проекты идентифицируются сочетанием источника и внешнего ID, а не названием. + +«Оплачено в МойСклад» — сумма проведённых исходящих платежей и расходных ордеров +за период в выбранной валюте. Проект определяется по явному полю документа; +из связанных заказов и приёмок он не угадывается. Непроведённые документы и +документы, исчезнувшие после полностью успешного обхода источника, исключаются. +Повторный импорт обновляет ту же запись. Последние полученные исходные документы сохраняются; отсутствующие платежи +помечаются флагом и не удаляются. Импортированные платежи доступны только для чтения. +При ошибке обхода оставшиеся документы не помечаются отсутствующими. + +Платёж может быть переводом между счетами, авансом, погашением долга, закупкой или возвратом: его нельзя +автоматически вычитать из прибыли поверх себестоимости. Поэтому оплаченные +суммы, ручные выплаты, закупки и общие расходы показаны отдельными колонками. +Ручные записи не следует создавать повторно для уже импортированных платежей. +При фильтре источника ручные факты относятся к нему через выбранный проект; +ручные факты без проекта не включаются в конкретный источник. + +Дополнительный API: `GET /api/v1/finance/analytics/project-payments/` с обычной +пагинацией. `section=projects` возвращает сводку, строки проектов, статьи и месяцы. +Параметр `project` — локальный ID проекта либо `unassigned`. Поле `project` +доступно в ручной записи, продаже и прямом расходе. Справочник проектов возвращается +в `analytics/options/`. Права и `no-store` совпадают с остальной аналитикой. + +Миграции: Commerce 0014 и Finance 0033. Обычная синхронизация теперь включает +проекты и исходящие платежи. Для отдельной загрузки без товаров и изображений: + +```bash +python manage.py sync_moysklad_projects +``` + +Контракты API: [проекты](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/Project.md), +[исходящие платежи](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/PaymentOut.md), +[расходные ордера](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/CashOut.md). diff --git a/frontend/src/app/finances/analytics/page.tsx b/frontend/src/app/finances/analytics/page.tsx new file mode 100644 index 00000000..3b1dca91 --- /dev/null +++ b/frontend/src/app/finances/analytics/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function CompanyAnalyticsPage() { + redirect("/finances?tab=analytics"); +} diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index 60750f60..7c0694f8 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -11,9 +11,11 @@ import { RefreshCw, Wallet, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { AppShell } from "@/components/AppShell"; +import { AnalyticsWorkspace } from "@/components/analytics/AnalyticsWorkspace"; import { PayrollDailyWorkSection } from "@/components/finance/PayrollDailyWorkSection"; import { PayrollStatementDetailModal } from "@/components/finance/PayrollStatementDetailModal"; import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace"; @@ -76,9 +78,21 @@ function EmptyPayroll() { } export default function FinancesPage() { + return }>; +} + +function FinancesContent() { const { user } = useUser(); + const router = useRouter(); + const searchParams = useSearchParams(); const showPayrollAdmin = canOpenPayrollAdmin(user); const [activeTab, setActiveTab] = usePayrollFinanceTab(user?.id, showPayrollAdmin); + const isWorkspaceTab = activeTab === "management" || activeTab === "analytics"; + useEffect(() => { + if (!user || searchParams.get("tab") !== "analytics") return; + if (showPayrollAdmin) setActiveTab("analytics"); + router.replace("/finances", { scroll: false }); + }, [router, searchParams, setActiveTab, showPayrollAdmin, user]); const [statements, setStatements] = useState([]); const [selectedStatement, setSelectedStatement] = useState(null); const [loading, setLoading] = useState(true); @@ -135,15 +149,17 @@ export default function FinancesPage() { return ( -
+

Финансы

{activeTab === "management" && showPayrollAdmin ? (
+ ) : activeTab === "analytics" && showPayrollAdmin ? ( +
) : activeTab === "work" ? (
) : null} @@ -151,6 +167,8 @@ export default function FinancesPage() { {activeTab === "management" && showPayrollAdmin ? (
+ ) : activeTab === "analytics" && showPayrollAdmin ? ( +

Аналитика компании

) : null}
@@ -187,6 +205,13 @@ export default function FinancesPage() { Управление ) : null} + {showPayrollAdmin ? ( + + ) : null} {activeTab === "management" && showPayrollAdmin ? (
@@ -203,6 +228,9 @@ export default function FinancesPage() { headerTargetId="finance-management-header" onDesktopWideModeChange={changeDesktopWideMode} /> + ) : activeTab === "analytics" && showPayrollAdmin ? ( + ) : activeTab === "work" ? ( ) : ( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 28cf5421..0231d9ec 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -874,7 +874,72 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { } } -.payroll-admin-records-table { +.analytics-records-responsive { + container-type: inline-size; + min-width: 0; +} + +.analytics-list-responsive { + container-type: inline-size; + min-width: 0; +} + +.analytics-list-toggle { + display: flex; +} + +.analytics-list-title, +.analytics-list-collapsed { + display: none; +} + +.analytics-list-wide .analytics-list-toggle, +.analytics-list-wide .analytics-records-compact { + display: none; +} +.analytics-list-wide .analytics-list-title { + display: flex; +} +.analytics-list-wide .analytics-list-content, +.analytics-list-wide .analytics-records-table { + display: block; +} +.analytics-report-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + align-items: start; +} +.analytics-layout-wide .analytics-report-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.analytics-layout-wide .analytics-list-featured { + grid-column: 1 / -1; +} +.analytics-layout-wide .analytics-report-grid > :last-child:nth-child(2) { + grid-column: 1 / -1; +} +.analytics-data-layout { + display: grid; + gap: 1.25rem; + align-items: start; +} +.analytics-list-wide.analytics-list-featured .analytics-data-side-by-side { + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); +} +.analytics-list-wide .analytics-records-table { + max-height: 24rem; + overflow: auto; +} +.analytics-list-wide .analytics-records-table thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--surface-primary); +} + +.payroll-admin-records-table, +.analytics-records-table { display: none; } diff --git a/frontend/src/app/products/layout.tsx b/frontend/src/app/products/layout.tsx new file mode 100644 index 00000000..9bf46dce --- /dev/null +++ b/frontend/src/app/products/layout.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +// Интерфейс модуля отложен. Импорт и API продолжают обслуживать аналитику. +export default function ProductsLayout() { + redirect("/"); +} diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index 8241d954..8189b859 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -41,7 +41,6 @@ const modelOrder: SearchModelType[] = [ "schedule_event", "procurement_request", "equipment", - "product", "task_board", "task", "guest", diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index fe1eccb0..4851dee9 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, PackageSearch, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react"; +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 Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -67,7 +67,8 @@ const navItems = [ { href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests }, // { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false }, - { href: "/products", label: "Товары", icon: PackageSearch }, + // Раздел сохранён для дальнейшей разработки; интеграции доступны в Django admin. + // { href: "/products", label: "Товары", icon: PackageSearch }, { 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 }, diff --git a/frontend/src/components/analytics/AnalyticsHelpButton.tsx b/frontend/src/components/analytics/AnalyticsHelpButton.tsx new file mode 100644 index 00000000..502d1e31 --- /dev/null +++ b/frontend/src/components/analytics/AnalyticsHelpButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useRef, useState, type ReactNode } from "react"; +import { CircleHelp } from "lucide-react"; +import { Modal } from "@/components/ui"; + +export function AnalyticsHelpButton({ title, children }: { title: string; children: ReactNode }) { + const [open, setOpen] = useState(false); + const trigger = useRef(null); + return <> + + { setOpen(false); trigger.current?.focus(); }}> +
{children}
+
+ ; +} diff --git a/frontend/src/components/analytics/AnalyticsHelpContent.tsx b/frontend/src/components/analytics/AnalyticsHelpContent.tsx new file mode 100644 index 00000000..d453cddd --- /dev/null +++ b/frontend/src/components/analytics/AnalyticsHelpContent.tsx @@ -0,0 +1,93 @@ +"use client"; + +import type { AnalyticsTab } from "@/hooks/usePayrollTabs"; + +export function AnalyticsHelpContent({ tab }: { tab: AnalyticsTab }) { + return <> +

Выберите внутреннюю вкладку, откройте «Фильтры» в шапке финансов, задайте период и нажмите «Показать». Источник и проект применяются там, где эти связи есть в данных. Отчёты строятся в одной выбранной валюте.

+

Кнопка разворота в шапке переключает обычный и широкий вид на компьютере. В широком виде графики находятся рядом с таблицами, а дополнительные наборы данных располагаются в две колонки. На узком экране вкладки прокручиваются по горизонтали, а списки по умолчанию свёрнуты. Нажмите заголовок списка со счётчиком, затем строку со стрелкой, чтобы раскрыть все показатели и доступные действия. Кнопка графика в заголовке списка открывает диаграмму отдельным окном. На узком экране графики показываются только в этих окнах. CSV содержит строки таблицы с учётом поиска.

+

Показатель графика можно переключить: суммы, количество и проценты показываются отдельно. Графики сводных списков учитывают поиск и все страницы результата; графики документов и журнала показывают только текущую страницу, что указано над диаграммой. Графики имеют одинаковую высоту. В сравнении показано до семи позиций: для суммируемых показателей длинный список сокращается до шести крупнейших по модулю и «Прочего» с суммой остальных. Проценты, средние значения, возраст и стаж не складываются: показываются семь крупнейших значений. Знак значений сохраняется. Пропуски данных не заменяются нулями.

+

«Рассчитано» — время построения отчёта. Последнюю загрузку источников и ошибки синхронизации администратор проверяет в админке, в разделе «Подключения интеграций».

+ {tab === "sales" && <> +

+ ¹ Прибыль = выручка после скидок и возвратов − себестоимость проданных + товаров с учётом возвратов − привязанные расходы. Это результат по + учтённым расходам, а не окончательная чистая прибыль компании. Общие + расходы и налоги не распределяются автоматически. +

+

+ Продажи учитываются по дате операции, расходы — по дате расхода. + Отменённые операции исключаются. Расходы без привязки к продаже + остаются в группе «Клиент не указан». Прибыль с неизвестной + себестоимостью обозначена «—». +

+

+ Закупки не вычитаются повторно из прибыли: себестоимость уже включена + в продажи. Денежные поступления и выплаты берутся из журнала фактов. + Отсутствие записей обозначено «—». Разные валюты не складываются. +

+

+ Данные разных источников не объединяются по предполагаемому совпадению + заказов. Если один и тот же оборот импортирован из двух систем, + выберите нужный источник. +

+ } + {tab === "people" && <> +

+ Зарплата — начисленная сумма до удержаний из опубликованной ревизии + расчёта. Включаются только расчётные периоды, целиком входящие в + выбранный диапазон. Несколько расчётов одного месяца суммируются. + Среднее по сотруднику делится на число месяцев с расчётом; среднее + компании — на число пар «сотрудник / месяц» с расчётом. +

+

+ Изменение зарплаты сравнивает первый и последний месяц с + опубликованными расчётами внутри выбранного диапазона; при отсутствии + одного из расчётов или нулевой начальной сумме выводится «—». Суммы + неполных месяцев и изменение графика могут влиять на сравнение. +

+

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

+

+ Налоги — фактически оплаченные суммы из журнала, а не удержания из + расчётного листка. Стаж учитывает приём, увольнение и повторный приём. + Для текущей численности без кадровой истории используется активность + учётной записи. Историческая динамика включает только сотрудников с + кадровой историей. +

+ } + {tab === "production" && <> +

+ Выпуск и производственные закупки берутся из журнала фактов по дате + записи. Закупку можно связать с конечным товаром; общие закупки + остаются отдельной строкой. Закупка материалов сама по себе не + подтверждает выпуск продукции. +

+

+ Продажи и прибыль используют те же расчёты, что и раздел «Продажи». ¹ + Общие расходы и налоги не распределяются. Фильтр источника относится к + продажам; журнал фактов хранится отдельно. Количество нужно вводить в + той же единице товара, в которой учитываются продажи. +

+ } + {tab === "projects" && <> +

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

+

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

+ } + {tab === "journal" && <> +

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

+

Укажите дату, вид операции, сумму или количество и связанные товар, проект или сотрудника. Состав полей зависит от вида операции. Право просмотра не даёт права добавлять и аннулировать записи.

+ } + ; +} diff --git a/frontend/src/components/analytics/AnalyticsLayout.tsx b/frontend/src/components/analytics/AnalyticsLayout.tsx new file mode 100644 index 00000000..be155e8d --- /dev/null +++ b/frontend/src/components/analytics/AnalyticsLayout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; + +const WideLayoutContext = createContext(false); +export const useAnalyticsWideLayout = () => useContext(WideLayoutContext); + +export function AnalyticsLayout({ enabled, children }: { enabled: boolean; children: ReactNode }) { + const container = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + if (!container.current) return; + const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + observer.observe(container.current); + return () => observer.disconnect(); + }, []); + const wide = enabled && width >= 928; + return +
{children}
+
; +} diff --git a/frontend/src/components/analytics/AnalyticsListSection.tsx b/frontend/src/components/analytics/AnalyticsListSection.tsx new file mode 100644 index 00000000..13adc4ad --- /dev/null +++ b/frontend/src/components/analytics/AnalyticsListSection.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { ChartNoAxesCombined, ChevronDown, List } from "lucide-react"; +import { Modal } from "@/components/ui"; +import { useAnalyticsWideLayout } from "./AnalyticsLayout"; + +export function AnalyticsListSection({ title, count, aside, chart, featured, children }: { + title: string; + count?: number; + aside?: ReactNode; + chart?: ReactNode; + featured?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const [chartOpen, setChartOpen] = useState(false); + const chartTrigger = useRef(null); + const layoutRef = useRef(null); + const recordsRef = useRef(null); + const [sideBySide, setSideBySide] = useState(false); + const wide = useAnalyticsWideLayout(); + const hasChart = Boolean(chart); + useEffect(() => { + const layout = layoutRef.current; + const records = recordsRef.current; + if (!wide || !featured || !hasChart || !layout || !records) return; + + let frame = 0; + const measure = () => { + const table = records.querySelector("table"); + if (!table) { + setSideBySide(false); + return; + } + // Measure intrinsic width independently of the current arrangement so + // switching to a full-width table cannot cause layout oscillation. + const previousWidth = table.style.width; + table.style.width = "min-content"; + const requiredWidth = table.getBoundingClientRect().width; + table.style.width = previousWidth; + const gap = parseFloat(getComputedStyle(layout).columnGap) || 0; + const tableColumnWidth = (layout.clientWidth - gap) * 1.15 / 2; + const scroller = table.closest(".analytics-records-table"); + const scrollbarWidth = scroller ? scroller.offsetWidth - scroller.clientWidth : 0; + setSideBySide(requiredWidth > 0 && requiredWidth <= tableColumnWidth - scrollbarWidth - 1); + }; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }; + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(layout); + resizeObserver.observe(records); + const mutationObserver = new MutationObserver(schedule); + mutationObserver.observe(records, { childList: true, characterData: true, subtree: true }); + schedule(); + return () => { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [wide, featured, hasChart]); + const contentId = useId(); + const heading = <> +