374 lines
14 KiB
Python
374 lines
14 KiB
Python
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]))
|