Corp/backend/api/v1/finance/analytics.py

374 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated, AnalyticsPermission]
def get(self, request):
section = request.query_params.get("section", "sales")
if section not in ["sales", "people", "production", "projects"]:
raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."})
params = report_params(request)
start, end, currency = (
params["date_from"],
params["date_to"],
params["currency"],
)
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):
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]
@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": [
{
"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):
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]))