333 lines
13 KiB
Python
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"),
|
|
)
|