from datetime import date from decimal import Decimal from django.db import transaction from django.db.models import OuterRef, Q, Subquery from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer 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 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 ( local_project_scope, payment_queryset, projects_report, ) from finance.analytics.sales import production_report, sales_report from finance.payroll.access import ( can_access_payroll_management, has_simple_admin_access, ) from projects.models import Project, ProjectExternalBinding from .views import NoStorePayrollResponseMixin 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"] = Project.objects.get(pk=int(attrs["project"])) except (ValueError, OverflowError, Project.DoesNotExist): raise serializers.ValidationError({"project": "Проект не найден."}) if ( attrs.get("source") and not attrs["project"] .bindings.filter(source=attrs["source"], object_type="project") .exists() ): 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 AnalyticsReportResponseSerializer(serializers.Serializer): period = inline_serializer( name="AnalyticsReportPeriod", fields={"from": serializers.DateField(), "to": serializers.DateField()}, ) currency = serializers.CharField() generated_at = serializers.DateTimeField() can_write = serializers.BooleanField() # Each section contains dynamically grouped metrics and table rows. people = serializers.DictField(required=False) sales = serializers.DictField(required=False) production = serializers.DictField(required=False) projects = serializers.DictField(required=False) class AnalyticsReportView(NoStorePayrollResponseMixin, APIView): permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] @extend_schema( parameters=[ ReportQuery, OpenApiParameter( "section", str, enum=["sales", "people", "production", "projects"] ), ], responses=AnalyticsReportResponseSerializer, ) def get(self, request): section = request.query_params.get("section", "sales") if section not in ["sales", "people", "production", "projects"]: raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."}) params = report_params(request) start, end, currency = ( params["date_from"], params["date_to"], params["currency"], ) project_scope = ( local_project_scope(params.get("source"), params.get("project")) if section != "people" else None ) 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, project_scope=project_scope, ) 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"), project_scope, ) return Response(json_values(result)) class ReportingEntrySerializer(serializers.ModelSerializer): project_name = serializers.CharField( source="project.name", read_only=True, default="" ) external_project_name = serializers.CharField( source="external_project.name", read_only=True, default="" ) product_name = serializers.CharField( source="product.name", read_only=True, default="" ) employee_name = serializers.SerializerMethodField() def get_employee_name(self, obj) -> str: return obj.employee.get_full_name() if obj.employee_id else "" class Meta: model = ReportingEntry fields = [ "id", "kind", "date", "reference", "description", "project", "project_name", "external_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", "external_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] @extend_schema(request=None, responses=ReportingEntrySerializer) @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 AnalyticsNamedOptionSerializer(serializers.Serializer): id = serializers.IntegerField() name = serializers.CharField() class AnalyticsProductOptionSerializer(serializers.Serializer): id = serializers.UUIDField() name = serializers.CharField() class AnalyticsProjectOptionSerializer(AnalyticsNamedOptionSerializer): source_ids = serializers.ListField(child=serializers.IntegerField()) archived = serializers.BooleanField() class AnalyticsOptionsResponseSerializer(serializers.Serializer): products = AnalyticsProductOptionSerializer(many=True) employees = AnalyticsNamedOptionSerializer(many=True) sources = AnalyticsNamedOptionSerializer(many=True) projects = AnalyticsProjectOptionSerializer(many=True) can_write = serializers.BooleanField() can_void = serializers.BooleanField() class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView): permission_classes = [permissions.IsAuthenticated, AnalyticsPermission] @extend_schema( parameters=[OpenApiParameter("search", str)], responses=AnalyticsOptionsResponseSerializer, ) 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": [ { "id": project.pk, "name": project.name, "source_ids": sorted( { binding.source_id for binding in project.bindings.all() if binding.object_type == "project" } ), "archived": project.status == Project.Status.ARCHIVED, } for project in Project.objects.prefetch_related("bindings") ], "can_write": can_write(request.user), "can_void": has_simple_admin_access(request.user) or request.user.has_perm("finance.change_reportingentry"), } ) class ProjectPaymentSerializer(serializers.ModelSerializer): project_name = serializers.SerializerMethodField() external_project_name = serializers.CharField( source="project.name", default="", read_only=True ) source_name = serializers.CharField(source="source.name", read_only=True) def get_project_name(self, obj) -> str: return obj.local_project_name or "Без привязки" class Meta: model = ProjectPayment fields = [ "id", "project", "project_name", "external_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) scope = local_project_scope(p.get("source"), p.get("project")) binding = ProjectExternalBinding.objects.filter( source_id=OuterRef("source_id"), object_type="project", external_id=OuterRef("project__external_id"), ) return payment_queryset( p["date_from"], p["date_to"], p["currency"], p.get("source"), scope=scope, ).annotate(local_project_name=Subquery(binding.values("project__name")[:1]))