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

333 lines
13 KiB
Python

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"),
)