Add project analytics and improve commerce integrations
This commit is contained in:
parent
76c9be260d
commit
cd64ee4ef7
3
.gitignore
vendored
3
.gitignore
vendored
@ -207,3 +207,6 @@ dump.rdb
|
||||
.playwright-cli/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Local planning documents and source materials
|
||||
/project-planning/
|
||||
|
||||
@ -136,7 +136,9 @@ class IntegrationConnectionSerializer(serializers.ModelSerializer):
|
||||
provider = attrs.get("provider", getattr(self.instance, "provider", None))
|
||||
if provider == IntegrationProvider.WILDBERRIES and auth_type != "token":
|
||||
raise serializers.ValidationError(
|
||||
{"auth_type": "Wildberries поддерживает авторизацию только по API-токену."}
|
||||
{
|
||||
"auth_type": "Wildberries поддерживает авторизацию только по API-токену."
|
||||
}
|
||||
)
|
||||
has_new_credentials = any(
|
||||
key in attrs for key in ("token", "login", "password")
|
||||
@ -327,6 +329,15 @@ class SaleSerializer(serializers.ModelSerializer):
|
||||
source_code = serializers.CharField(source="source.code", read_only=True)
|
||||
lines = SaleLineSerializer(many=True, read_only=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
project = attrs.get("project", getattr(self.instance, "project", None))
|
||||
source = attrs.get("source", getattr(self.instance, "source", None))
|
||||
if project and source and project.source_id != source.pk:
|
||||
raise serializers.ValidationError(
|
||||
{"project": "Проект относится к другому источнику."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
model = Sale
|
||||
fields = [
|
||||
@ -338,6 +349,9 @@ class SaleSerializer(serializers.ModelSerializer):
|
||||
"number",
|
||||
"event_type",
|
||||
"channel",
|
||||
"customer_external_id",
|
||||
"customer_name",
|
||||
"project",
|
||||
"status",
|
||||
"counts_as_sale",
|
||||
"currency",
|
||||
@ -360,6 +374,7 @@ class CostSerializer(serializers.ModelSerializer):
|
||||
"source_code",
|
||||
"product",
|
||||
"sale_line",
|
||||
"project",
|
||||
"external_id",
|
||||
"channel",
|
||||
"cost_type",
|
||||
@ -374,9 +389,23 @@ class CostSerializer(serializers.ModelSerializer):
|
||||
def validate(self, attrs):
|
||||
product = attrs.get("product", getattr(self.instance, "product", None))
|
||||
sale_line = attrs.get("sale_line", getattr(self.instance, "sale_line", None))
|
||||
if product is None and sale_line is None:
|
||||
project = attrs.get("project", getattr(self.instance, "project", None))
|
||||
source = attrs.get("source", getattr(self.instance, "source", None))
|
||||
if project and source and project.source_id != source.pk:
|
||||
raise serializers.ValidationError(
|
||||
"Нужно указать товар или позицию продажи."
|
||||
{"project": "Проект относится к другому источнику."}
|
||||
)
|
||||
if (
|
||||
project
|
||||
and sale_line
|
||||
and sale_line.sale.project_id not in [None, project.pk]
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"project": "Проект не совпадает с проектом продажи."}
|
||||
)
|
||||
if product is None and sale_line is None and project is None:
|
||||
raise serializers.ValidationError(
|
||||
"Нужно указать товар, позицию продажи или проект."
|
||||
)
|
||||
external_id = attrs.get(
|
||||
"external_id", getattr(self.instance, "external_id", "")
|
||||
|
||||
332
backend/api/v1/finance/analytics.py
Normal file
332
backend/api/v1/finance/analytics.py
Normal file
@ -0,0 +1,332 @@
|
||||
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"),
|
||||
)
|
||||
@ -1,4 +1,11 @@
|
||||
from django.urls import path
|
||||
from .analytics import (
|
||||
ProjectPaymentListView,
|
||||
AnalyticsOptionsView,
|
||||
AnalyticsReportView,
|
||||
ReportingEntryListView,
|
||||
ReportingEntryVoidView,
|
||||
)
|
||||
|
||||
from .views import (
|
||||
OwnPayrollStatementAcknowledgeView,
|
||||
@ -58,6 +65,23 @@ from .admin import (
|
||||
app_name = "finance-payroll"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"analytics/project-payments/",
|
||||
ProjectPaymentListView.as_view(),
|
||||
name="analytics-project-payments",
|
||||
),
|
||||
path("analytics/report/", AnalyticsReportView.as_view(), name="analytics-report"),
|
||||
path(
|
||||
"analytics/options/", AnalyticsOptionsView.as_view(), name="analytics-options"
|
||||
),
|
||||
path(
|
||||
"analytics/entries/", ReportingEntryListView.as_view(), name="analytics-entries"
|
||||
),
|
||||
path(
|
||||
"analytics/entries/<int:pk>/void/",
|
||||
ReportingEntryVoidView.as_view(),
|
||||
name="analytics-entry-void",
|
||||
),
|
||||
path(
|
||||
"payroll/management-access/",
|
||||
PayrollManagementAccessSettingsView.as_view(),
|
||||
|
||||
@ -587,7 +587,11 @@ def search_api_view(request: DRFRequest) -> Response:
|
||||
|
||||
# Проверяем весь набор совпадений: раннее обрезание общего индекса приводило
|
||||
# к тому, что многочисленные недоступные записи вытесняли доступные из выдачи.
|
||||
search_results = watson_search(query)
|
||||
# Модуль товаров отложен; индекс сохраняем для будущего интерфейса,
|
||||
# но не предлагаем пользователям переходы на скрытые страницы.
|
||||
search_results = watson_search(query).exclude(
|
||||
content_type__app_label="commerce", content_type__model="product"
|
||||
)
|
||||
search_objects = _materialize_search_objects(search_results)
|
||||
|
||||
# Группируем и форматируем результаты
|
||||
|
||||
@ -1,12 +1,28 @@
|
||||
from django.contrib import admin
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
|
||||
from .forms import IntegrationConnectionAdminForm
|
||||
from .integrations.moysklad import MoySkladError
|
||||
from .integrations.wildberries import WildberriesError
|
||||
from .services import test_moysklad_connection, test_wildberries_connection
|
||||
from .services.credentials import CredentialError
|
||||
from .tasks import enqueue_connection_sync
|
||||
|
||||
from .models import (
|
||||
Cost,
|
||||
ExternalProject,
|
||||
IntegrationConnection,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
IntegrationSyncRun,
|
||||
Product,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
ProjectPayment,
|
||||
Sale,
|
||||
SaleLine,
|
||||
SourceRecord,
|
||||
@ -17,13 +33,123 @@ from .models import (
|
||||
|
||||
@admin.register(IntegrationConnection)
|
||||
class IntegrationConnectionAdmin(admin.ModelAdmin):
|
||||
list_display = ["provider", "source", "status", "account_name", "updated_at"]
|
||||
form = IntegrationConnectionAdminForm
|
||||
list_display = [
|
||||
"provider", "source", "status", "account_name", "auto_sync",
|
||||
"last_sync_finished_at", "next_sync_at",
|
||||
]
|
||||
list_filter = ["provider", "status"]
|
||||
readonly_fields = ["encrypted_credentials", "last_stats", "task_id"]
|
||||
search_fields = ["source__name", "account_name"]
|
||||
readonly_fields = [
|
||||
"source", "status", "account_name", "last_error", "last_tested_at",
|
||||
"last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id",
|
||||
"next_sync_at", "created_by", "created_at", "updated_at",
|
||||
"credentials_configured",
|
||||
]
|
||||
fieldsets = [
|
||||
("Подключение", {"fields": ["provider", "source", "auth_type", "credentials_configured"]}),
|
||||
("Реквизиты", {"fields": ["token", "login", "password"]}),
|
||||
("Расписание", {"fields": ["auto_sync", "sync_interval_minutes", "next_sync_at"]}),
|
||||
("Состояние", {"fields": [
|
||||
"status", "account_name", "last_error", "last_tested_at",
|
||||
"last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id",
|
||||
]}),
|
||||
("История", {"fields": ["created_by", "created_at", "updated_at"], "classes": ["collapse"]}),
|
||||
]
|
||||
actions = ["test_connections", "sync_connections"]
|
||||
|
||||
@admin.display(boolean=True, description="Реквизиты сохранены")
|
||||
def credentials_configured(self, obj):
|
||||
return bool(obj.encrypted_credentials)
|
||||
|
||||
@method_decorator(sensitive_post_parameters("token", "login", "password"))
|
||||
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
||||
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
obj.source, _ = SourceSystem.objects.get_or_create(
|
||||
code=obj.provider,
|
||||
defaults={"name": IntegrationProvider(obj.provider).label},
|
||||
)
|
||||
obj.created_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="Проверить выбранные подключения", permissions=["change"])
|
||||
def test_connections(self, request, queryset):
|
||||
for connection in queryset:
|
||||
if connection.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}:
|
||||
self.message_user(request, "Дождитесь завершения синхронизации.", messages.WARNING)
|
||||
continue
|
||||
test = {
|
||||
"moysklad": test_moysklad_connection,
|
||||
"wildberries": test_wildberries_connection,
|
||||
}.get(connection.provider)
|
||||
if test is None:
|
||||
self.message_user(request, "Этот сервис пока не поддерживается.", messages.ERROR)
|
||||
continue
|
||||
try:
|
||||
context = test(connection)
|
||||
except (MoySkladError, WildberriesError, CredentialError) as exc:
|
||||
connection.status = IntegrationStatus.ERROR
|
||||
connection.last_error = str(exc)
|
||||
connection.last_tested_at = timezone.now()
|
||||
connection.save(update_fields=["status", "last_error", "last_tested_at", "updated_at"])
|
||||
self.message_user(request, f"{connection.get_provider_display()}: {exc}", messages.ERROR)
|
||||
continue
|
||||
connection.status = IntegrationStatus.READY
|
||||
connection.account_name = context["account_name"]
|
||||
connection.last_error = ""
|
||||
connection.last_tested_at = timezone.now()
|
||||
connection.next_sync_at = (
|
||||
timezone.now() + timedelta(minutes=connection.sync_interval_minutes)
|
||||
if connection.auto_sync else None
|
||||
)
|
||||
connection.source.metadata = {
|
||||
**connection.source.metadata, "account_id": context["account_id"],
|
||||
}
|
||||
if context.get("tin"):
|
||||
connection.source.metadata["tin"] = context["tin"]
|
||||
connection.source.save(update_fields=["metadata", "updated_at"])
|
||||
connection.save(update_fields=[
|
||||
"status", "account_name", "last_error", "last_tested_at", "next_sync_at", "updated_at",
|
||||
])
|
||||
self.message_user(request, f"{connection.get_provider_display()}: подключение проверено.")
|
||||
|
||||
@admin.action(description="Синхронизировать выбранные подключения", permissions=["change"])
|
||||
def sync_connections(self, request, queryset):
|
||||
for connection in queryset:
|
||||
if not connection.encrypted_credentials:
|
||||
self.message_user(request, "Сначала сохраните реквизиты подключения.", messages.ERROR)
|
||||
continue
|
||||
try:
|
||||
run = enqueue_connection_sync(connection)
|
||||
except Exception:
|
||||
# Публикация задачи уже сохраняет ошибку в подключении и журнале.
|
||||
self.message_user(request, "Не удалось запустить синхронизацию. Проверьте историю запусков.", messages.ERROR)
|
||||
continue
|
||||
if run is None:
|
||||
self.message_user(request, "Синхронизация уже выполняется.", messages.WARNING)
|
||||
else:
|
||||
self.message_user(request, f"{connection.get_provider_display()}: синхронизация поставлена в очередь.")
|
||||
|
||||
|
||||
class ImportedDataAdmin(admin.ModelAdmin):
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return [field.name for field in self.model._meta.fields]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(IntegrationSyncRun)
|
||||
class IntegrationSyncRunAdmin(admin.ModelAdmin):
|
||||
class IntegrationSyncRunAdmin(ImportedDataAdmin):
|
||||
list_display = [
|
||||
"connection",
|
||||
"status",
|
||||
@ -38,6 +164,21 @@ class IntegrationSyncRunAdmin(admin.ModelAdmin):
|
||||
readonly_fields = [field.name for field in IntegrationSyncRun._meta.fields]
|
||||
|
||||
|
||||
@admin.register(ExternalProject)
|
||||
class ExternalProjectAdmin(ImportedDataAdmin):
|
||||
list_display = ["name", "code", "source", "archived", "synced_at"]
|
||||
list_filter = ["source", "archived"]
|
||||
search_fields = ["name", "code", "external_id"]
|
||||
|
||||
|
||||
@admin.register(ProjectPayment)
|
||||
class ProjectPaymentAdmin(ImportedDataAdmin):
|
||||
list_display = ["number", "document_type", "project", "amount", "currency", "occurred_at", "applicable"]
|
||||
list_filter = ["source", "document_type", "currency", "applicable", "missing_from_source"]
|
||||
search_fields = ["number", "external_id", "project__name", "payee", "expense_category"]
|
||||
list_select_related = ["project", "source"]
|
||||
|
||||
|
||||
class ProductBindingInline(admin.TabularInline):
|
||||
model = ProductBinding
|
||||
extra = 0
|
||||
|
||||
72
backend/commerce/forms.py
Normal file
72
backend/commerce/forms.py
Normal file
@ -0,0 +1,72 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.models import IntegrationConnection, IntegrationStatus
|
||||
from commerce.services.credentials import encrypt_credentials
|
||||
|
||||
|
||||
class IntegrationConnectionAdminForm(forms.ModelForm):
|
||||
token = forms.CharField(
|
||||
label="Новый API-токен", required=False,
|
||||
widget=forms.PasswordInput(render_value=False),
|
||||
help_text="Оставьте пустым, чтобы сохранить текущие реквизиты.",
|
||||
)
|
||||
login = forms.CharField(label="Новый API-логин", required=False)
|
||||
password = forms.CharField(
|
||||
label="Новый пароль", required=False, strip=False,
|
||||
widget=forms.PasswordInput(render_value=False),
|
||||
help_text="Для смены реквизитов укажите и логин, и пароль.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IntegrationConnection
|
||||
fields = [
|
||||
"provider", "auth_type", "token", "login", "password",
|
||||
"auto_sync", "sync_interval_minutes",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance._state.adding and "provider" in self.fields:
|
||||
self.fields["provider"].disabled = True
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if self.instance.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}:
|
||||
raise forms.ValidationError("Дождитесь завершения текущей синхронизации.")
|
||||
auth_type = data.get("auth_type")
|
||||
if data.get("provider") == "wildberries" and auth_type != "token":
|
||||
self.add_error("auth_type", "Wildberries поддерживает только API-токен.")
|
||||
credentials_required = (
|
||||
not self.instance.encrypted_credentials
|
||||
or auth_type != self.instance.auth_type
|
||||
or any(data.get(key) for key in ("token", "login", "password"))
|
||||
)
|
||||
self.new_credentials = None
|
||||
if credentials_required:
|
||||
keys = ("token",) if auth_type == "token" else ("login", "password")
|
||||
for key in keys:
|
||||
if not data.get(key):
|
||||
self.add_error(key, "Укажите реквизиты выбранного способа авторизации.")
|
||||
if all(data.get(key) for key in keys):
|
||||
self.new_credentials = {key: data[key] for key in keys}
|
||||
return data
|
||||
|
||||
def save(self, commit=True):
|
||||
connection = super().save(commit=False)
|
||||
if self.new_credentials:
|
||||
connection.encrypted_credentials = encrypt_credentials(self.new_credentials)
|
||||
connection.status = IntegrationStatus.DISCONNECTED
|
||||
connection.last_error = ""
|
||||
connection.last_tested_at = None
|
||||
if {"auto_sync", "sync_interval_minutes"}.intersection(self.changed_data):
|
||||
connection.next_sync_at = (
|
||||
timezone.now() + timedelta(minutes=connection.sync_interval_minutes)
|
||||
if connection.auto_sync else None
|
||||
)
|
||||
if commit:
|
||||
connection.save()
|
||||
self.save_m2m()
|
||||
return connection
|
||||
@ -36,6 +36,7 @@ class MoySkladClient:
|
||||
READ_TIMEOUT_SECONDS = 90.0
|
||||
|
||||
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
|
||||
self._project_reference_cache = {}
|
||||
self.base_url = (
|
||||
getattr(settings, "MOYSKLAD_API_BASE_URL", self.DEFAULT_BASE_URL).rstrip(
|
||||
"/"
|
||||
@ -139,6 +140,22 @@ class MoySkladClient:
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if (
|
||||
exc.response.status_code in {429, 500, 502, 503, 504}
|
||||
and attempt < total_attempts
|
||||
):
|
||||
retry_delay = self.GET_RETRY_DELAYS[attempt - 1]
|
||||
logger.warning(
|
||||
"MoySklad HTTP %s; retrying GET",
|
||||
exc.response.status_code,
|
||||
extra={
|
||||
"endpoint": endpoint,
|
||||
"attempt": attempt,
|
||||
"request_offset": (params or {}).get("offset"),
|
||||
},
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
raise MoySkladError(self._http_status_message(exc.response)) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.error(
|
||||
@ -167,9 +184,7 @@ class MoySkladClient:
|
||||
if status in {401, 403}:
|
||||
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
|
||||
elif status == 400:
|
||||
message = (
|
||||
"МойСклад не принял запрос. Проверьте access token и права пользователя."
|
||||
)
|
||||
message = "МойСклад не принял запрос. Проверьте access token и права пользователя."
|
||||
elif status == 429:
|
||||
message = "МойСклад временно ограничил частоту запросов."
|
||||
else:
|
||||
@ -236,7 +251,9 @@ class MoySkladClient:
|
||||
for chunk in response.iter_bytes():
|
||||
received += len(chunk)
|
||||
if received > self.MAX_IMAGE_BYTES:
|
||||
raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.")
|
||||
raise MoySkladError(
|
||||
"Изображение МойСклад превышает лимит 25 МБ."
|
||||
)
|
||||
chunks.append(chunk)
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
|
||||
except MoySkladError:
|
||||
@ -360,7 +377,7 @@ class MoySkladClient:
|
||||
params={
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"expand": "positions.assortment,state,salesChannel,rate.currency",
|
||||
"expand": "positions.assortment,state,salesChannel,rate.currency,agent,project",
|
||||
},
|
||||
)
|
||||
rows = payload.get("rows", [])
|
||||
@ -373,6 +390,66 @@ class MoySkladClient:
|
||||
return
|
||||
offset += limit
|
||||
|
||||
def _payment_reference(self, reference):
|
||||
if not reference or reference.get("name"):
|
||||
return reference or {}
|
||||
meta = reference.get("meta") or {}
|
||||
kind = meta.get("type")
|
||||
external_id = (
|
||||
reference.get("id")
|
||||
or urlparse(meta.get("href", "")).path.rstrip("/").split("/")[-1]
|
||||
)
|
||||
if kind not in {
|
||||
"counterparty",
|
||||
"organization",
|
||||
"employee",
|
||||
"expenseitem",
|
||||
"currency",
|
||||
} or not re.fullmatch(r"[0-9a-fA-F-]{36}", external_id):
|
||||
raise MoySkladError("Некорректная ссылка в платеже МойСклад.")
|
||||
key = (kind, external_id)
|
||||
if key not in self._project_reference_cache:
|
||||
data = self._get(f"/entity/{kind}/{external_id}")
|
||||
self._project_reference_cache[key] = {
|
||||
k: data[k]
|
||||
for k in ["id", "meta", "name", "isoCode", "code"]
|
||||
if k in data
|
||||
}
|
||||
return self._project_reference_cache[key]
|
||||
|
||||
def _payment_references(self, row):
|
||||
# Resolve shared references once per run instead of repeatedly asking
|
||||
# the API to expand hundreds of the same objects on every large page.
|
||||
row = dict(row)
|
||||
for key in ["expenseItem", "agent"]:
|
||||
row[key] = self._payment_reference(row.get(key))
|
||||
rate = dict(row.get("rate") or {})
|
||||
rate["currency"] = self._payment_reference(rate.get("currency"))
|
||||
row["rate"] = rate
|
||||
return row
|
||||
|
||||
def iter_project_data(
|
||||
self, entity_type: str, *, archived=False, limit=1000
|
||||
) -> Iterator[dict]:
|
||||
if entity_type not in {"project", "paymentout", "cashout"}:
|
||||
raise MoySkladError("Неподдерживаемый тип данных проекта.")
|
||||
offset = 0
|
||||
while True:
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if entity_type == "project":
|
||||
params["filter"] = f"archived={str(archived).lower()}"
|
||||
payload = self._get(f"/entity/{entity_type}", params=params)
|
||||
rows = payload.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
raise MoySkladError("В ответе проектов отсутствует список rows.")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise MoySkladError("Некорректная запись проекта.")
|
||||
yield row if entity_type == "project" else self._payment_references(row)
|
||||
if len(rows) < limit:
|
||||
return
|
||||
offset += limit
|
||||
|
||||
def iter_stock_by_store(self, *, limit: int = 1000) -> Iterator[dict]:
|
||||
offset = 0
|
||||
while True:
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
|
||||
from commerce.models import IntegrationConnection
|
||||
from commerce.services.credentials import decrypt_credentials
|
||||
from commerce.services.moysklad_projects import (
|
||||
sync_project_catalog,
|
||||
sync_project_payments,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Загрузить проекты и исходящие платежи МойСклад без каталога товаров и изображений."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("connection_id")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
connection = IntegrationConnection.objects.filter(
|
||||
pk=options["connection_id"], provider="moysklad"
|
||||
).first()
|
||||
if not connection:
|
||||
raise CommandError("Подключение МойСклад не найдено.")
|
||||
try:
|
||||
with MoySkladClient(
|
||||
auth_type=connection.auth_type,
|
||||
credentials=decrypt_credentials(connection),
|
||||
) as client:
|
||||
projects = sync_project_catalog(connection.source, client)
|
||||
payments = sync_project_payments(
|
||||
connection.source,
|
||||
client,
|
||||
progress=lambda count: (
|
||||
self.stdout.write(f"Платежей обработано: {count}")
|
||||
if count % 1000 == 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
except MoySkladError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Проекты: {projects}; исходящие платежи: {payments}.")
|
||||
)
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 06:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("commerce", "0011_integrationsyncrun_progress_heartbeat"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sale",
|
||||
name="customer_external_id",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, verbose_name="ID клиента в источнике"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sale",
|
||||
name="customer_name",
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name="Клиент"),
|
||||
),
|
||||
]
|
||||
36
backend/commerce/migrations/0013_restore_sale_customers.py
Normal file
36
backend/commerce/migrations/0013_restore_sale_customers.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def restore_customers(apps, schema_editor):
|
||||
sale_model = apps.get_model("commerce", "Sale")
|
||||
queryset = sale_model.objects.using(schema_editor.connection.alias).filter(
|
||||
customer_external_id="", customer_name=""
|
||||
)
|
||||
batch = []
|
||||
for pk, agent in (
|
||||
queryset.filter(source_record__isnull=False)
|
||||
.values_list("pk", "source_record__payload__agent")
|
||||
.iterator(chunk_size=500)
|
||||
):
|
||||
if not isinstance(agent, dict):
|
||||
continue
|
||||
meta = agent.get("meta") or {}
|
||||
href = str(meta.get("href") or "") if isinstance(meta, dict) else ""
|
||||
customer_id = str(agent.get("id") or href.rstrip("/").rsplit("/", 1)[-1])
|
||||
batch.append(
|
||||
sale_model(
|
||||
pk=pk,
|
||||
customer_external_id=customer_id[:255],
|
||||
customer_name=str(agent.get("name") or "")[:255],
|
||||
)
|
||||
)
|
||||
if len(batch) >= 500:
|
||||
queryset.bulk_update(batch, ["customer_external_id", "customer_name"])
|
||||
batch = []
|
||||
if batch:
|
||||
queryset.bulk_update(batch, ["customer_external_id", "customer_name"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("commerce", "0012_sale_customer_external_id_sale_customer_name")]
|
||||
operations = [migrations.RunPython(restore_customers, migrations.RunPython.noop)]
|
||||
@ -0,0 +1,105 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 07:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('commerce', '0013_restore_sale_customers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExternalProject',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('external_id', models.CharField(max_length=255)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('code', models.CharField(blank=True, max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('archived', models.BooleanField(default=False)),
|
||||
('synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name', 'pk'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectPayment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('external_id', models.CharField(max_length=255)),
|
||||
('document_type', models.CharField(choices=[('paymentout', 'Исходящий платёж'), ('cashout', 'Расходный ордер')], max_length=16)),
|
||||
('number', models.CharField(blank=True, max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('expense_category', models.CharField(blank=True, max_length=255)),
|
||||
('payee', models.CharField(blank=True, max_length=255)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
|
||||
('currency', models.CharField(max_length=3)),
|
||||
('occurred_at', models.DateTimeField(db_index=True)),
|
||||
('applicable', models.BooleanField(default=False)),
|
||||
('missing_from_source', models.BooleanField(default=False)),
|
||||
('seen_at', models.DateTimeField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-occurred_at', '-pk'],
|
||||
},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='cost',
|
||||
name='commerce_cost_has_target',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationsyncrun',
|
||||
name='stage',
|
||||
field=models.CharField(blank=True, choices=[('projects', 'Проекты'), ('project_payments', 'Расходы проектов'), ('catalog', 'Каталог'), ('images', 'Изображения'), ('variant_links', 'Связи вариантов'), ('stocks', 'Остатки'), ('sales', 'Продажи'), ('returns', 'Возвраты'), ('finalizing', 'Завершение')], default='', max_length=32, verbose_name='Этап'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='externalproject',
|
||||
name='source',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cost',
|
||||
name='project',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sale',
|
||||
name='project',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='cost',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('product__isnull', False), ('sale_line__isnull', False), ('project__isnull', False), _connector='OR'), name='commerce_cost_has_target'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectpayment',
|
||||
name='project',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectpayment',
|
||||
name='source',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectpayment',
|
||||
name='source_record',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcerecord'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='externalproject',
|
||||
constraint=models.UniqueConstraint(fields=('source', 'external_id'), name='commerce_project_source_id'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='projectpayment',
|
||||
constraint=models.UniqueConstraint(fields=('source', 'document_type', 'external_id'), name='commerce_project_payment_id'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='projectpayment',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('amount__gte', 0)), name='commerce_project_payment_amount'),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,6 @@
|
||||
from .costs import Cost, CostType
|
||||
from .product import Product, ProductImage, ProductKind
|
||||
from .projects import ExternalProject, ProjectPayment
|
||||
from .sales import Sale, SaleEventType, SaleLine
|
||||
from .source import (
|
||||
IntegrationConnection,
|
||||
@ -14,6 +15,8 @@ from .source import (
|
||||
from .stock import StockSnapshot
|
||||
|
||||
__all__ = [
|
||||
"ExternalProject",
|
||||
"ProjectPayment",
|
||||
"Cost",
|
||||
"CostType",
|
||||
"IntegrationConnection",
|
||||
|
||||
@ -19,6 +19,9 @@ class Cost(models.Model):
|
||||
"""Additional product- or sale-line-level expense."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
project = models.ForeignKey(
|
||||
"commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
source = models.ForeignKey(
|
||||
"commerce.SourceSystem",
|
||||
on_delete=models.PROTECT,
|
||||
@ -57,7 +60,9 @@ class Cost(models.Model):
|
||||
verbose_name_plural = "Расходы"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(product__isnull=False) | Q(sale_line__isnull=False),
|
||||
condition=Q(product__isnull=False)
|
||||
| Q(sale_line__isnull=False)
|
||||
| Q(project__isnull=False),
|
||||
name="commerce_cost_has_target",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
|
||||
60
backend/commerce/models/projects.py
Normal file
60
backend/commerce/models/projects.py
Normal file
@ -0,0 +1,60 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ExternalProject(models.Model):
|
||||
source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT)
|
||||
external_id = models.CharField(max_length=255)
|
||||
name = models.CharField(max_length=255)
|
||||
code = models.CharField(max_length=255, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
archived = models.BooleanField(default=False)
|
||||
synced_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name", "pk"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["source", "external_id"], name="commerce_project_source_id"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProjectPayment(models.Model):
|
||||
"""Outgoing cash documents; never automatically deducted from sale profit."""
|
||||
|
||||
source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT)
|
||||
source_record = models.ForeignKey("commerce.SourceRecord", on_delete=models.PROTECT)
|
||||
project = models.ForeignKey(
|
||||
ExternalProject, null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
external_id = models.CharField(max_length=255)
|
||||
document_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=[("paymentout", "Исходящий платёж"), ("cashout", "Расходный ордер")],
|
||||
)
|
||||
number = models.CharField(max_length=255, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
expense_category = models.CharField(max_length=255, blank=True)
|
||||
payee = models.CharField(max_length=255, blank=True)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
currency = models.CharField(max_length=3)
|
||||
occurred_at = models.DateTimeField(db_index=True)
|
||||
applicable = models.BooleanField(default=False)
|
||||
missing_from_source = models.BooleanField(default=False)
|
||||
seen_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-occurred_at", "-pk"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["source", "document_type", "external_id"],
|
||||
name="commerce_project_payment_id",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(amount__gte=0),
|
||||
name="commerce_project_payment_amount",
|
||||
),
|
||||
]
|
||||
@ -14,6 +14,9 @@ class Sale(models.Model):
|
||||
"""Normalized sale/order event supplied by an external or internal system."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
project = models.ForeignKey(
|
||||
"commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
source = models.ForeignKey(
|
||||
"commerce.SourceSystem",
|
||||
on_delete=models.PROTECT,
|
||||
@ -37,6 +40,10 @@ class Sale(models.Model):
|
||||
)
|
||||
number = models.CharField("Номер", max_length=128, blank=True)
|
||||
channel = models.CharField("Канал продаж", max_length=64, blank=True, db_index=True)
|
||||
customer_external_id = models.CharField(
|
||||
"ID клиента в источнике", max_length=255, blank=True
|
||||
)
|
||||
customer_name = models.CharField("Клиент", max_length=255, blank=True)
|
||||
status = models.CharField("Статус источника", max_length=128, blank=True)
|
||||
counts_as_sale = models.BooleanField(
|
||||
"Учитывать в продажах",
|
||||
|
||||
@ -127,6 +127,8 @@ class IntegrationStatus(models.TextChoices):
|
||||
|
||||
|
||||
class IntegrationSyncStage(models.TextChoices):
|
||||
PROJECTS = "projects", "Проекты"
|
||||
PROJECT_PAYMENTS = "project_payments", "Расходы проектов"
|
||||
CATALOG = "catalog", "Каталог"
|
||||
IMAGES = "images", "Изображения"
|
||||
VARIANT_LINKS = "variant_links", "Связи вариантов"
|
||||
|
||||
141
backend/commerce/services/moysklad_projects.py
Normal file
141
backend/commerce/services/moysklad_projects.py
Normal file
@ -0,0 +1,141 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladError
|
||||
from commerce.models import ExternalProject, ProjectPayment, Sale, SourceRecord
|
||||
from commerce.services.ingestion import ingest_source_record
|
||||
from commerce.services.moysklad_sync import (
|
||||
_document_currency,
|
||||
_external_id,
|
||||
_money,
|
||||
_reference_external_id,
|
||||
_source_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def project_reference(source, row):
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
external_id = _reference_external_id(row)
|
||||
if not external_id:
|
||||
return None
|
||||
project, _ = ExternalProject.objects.get_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
defaults={"name": str(row.get("name") or external_id)[:255]},
|
||||
)
|
||||
# A reference must not erase information obtained from the full catalogue.
|
||||
if row.get("name") and project.name != row["name"]:
|
||||
project.name = str(row["name"])[:255]
|
||||
project.save(update_fields=["name"])
|
||||
return project
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def normalize_project(source, row):
|
||||
external_id = _external_id(row)
|
||||
if not external_id or not row.get("name"):
|
||||
raise MoySkladError("В проекте отсутствует ID или название.")
|
||||
ingest_source_record(
|
||||
source=source,
|
||||
entity_type="project",
|
||||
external_id=external_id,
|
||||
payload=row,
|
||||
source_updated_at=_source_updated_at(row),
|
||||
)
|
||||
project, _ = ExternalProject.objects.update_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
defaults={
|
||||
"name": str(row["name"])[:255],
|
||||
"code": str(row.get("code") or "")[:255],
|
||||
"description": str(row.get("description") or ""),
|
||||
"archived": bool(row.get("archived")),
|
||||
"synced_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def normalize_project_payment(source, row, document_type, seen_at):
|
||||
if document_type not in {"paymentout", "cashout"}:
|
||||
raise MoySkladError("Неподдерживаемый платёж проекта.")
|
||||
external_id = _external_id(row)
|
||||
occurred_at = _source_updated_at({"updated": row.get("moment")})
|
||||
if not external_id or not occurred_at or row.get("sum") is None:
|
||||
raise MoySkladError("В платеже отсутствует ID, дата или сумма.")
|
||||
amount = _money(row["sum"])
|
||||
if amount < 0:
|
||||
raise MoySkladError("Отрицательная сумма исходящего платежа.")
|
||||
record = ingest_source_record(
|
||||
source=source,
|
||||
entity_type=document_type,
|
||||
external_id=external_id,
|
||||
payload=row,
|
||||
source_updated_at=_source_updated_at(row),
|
||||
).record
|
||||
payment, _ = ProjectPayment.objects.update_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
document_type=document_type,
|
||||
defaults={
|
||||
"source_record": record,
|
||||
"project": project_reference(source, row.get("project")),
|
||||
"number": str(row.get("name") or "")[:255],
|
||||
"description": str(
|
||||
row.get("paymentPurpose") or row.get("description") or ""
|
||||
),
|
||||
"expense_category": str((row.get("expenseItem") or {}).get("name") or "")[
|
||||
:255
|
||||
],
|
||||
"payee": str((row.get("agent") or {}).get("name") or "")[:255],
|
||||
"amount": amount,
|
||||
"currency": _document_currency(row),
|
||||
"occurred_at": occurred_at,
|
||||
"applicable": bool(row.get("applicable", False)) and not row.get("deleted"),
|
||||
"seen_at": seen_at,
|
||||
"missing_from_source": False,
|
||||
},
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
def restore_sale_projects(source):
|
||||
count = 0
|
||||
records = SourceRecord.objects.filter(
|
||||
source=source, entity_type__in=["demand", "salesreturn"]
|
||||
)
|
||||
for record in records.iterator(chunk_size=500):
|
||||
project = project_reference(source, record.payload.get("project"))
|
||||
if project:
|
||||
count += Sale.objects.filter(
|
||||
source_record=record, project__isnull=True
|
||||
).update(project=project)
|
||||
return count
|
||||
|
||||
|
||||
def sync_project_catalog(source, client):
|
||||
count = 0
|
||||
for archived in [False, True]:
|
||||
for row in client.iter_project_data("project", archived=archived):
|
||||
normalize_project(source, row)
|
||||
count += 1
|
||||
restore_sale_projects(source)
|
||||
return count
|
||||
|
||||
|
||||
def sync_project_payments(source, client, progress=None):
|
||||
count = 0
|
||||
for document_type in ["paymentout", "cashout"]:
|
||||
seen_at = timezone.now()
|
||||
for row in client.iter_project_data(document_type):
|
||||
normalize_project_payment(source, row, document_type, seen_at)
|
||||
count += 1
|
||||
if progress and count % 25 == 0:
|
||||
progress(count)
|
||||
# Only a fully successful scan may retire missing documents. Keep history.
|
||||
ProjectPayment.objects.filter(
|
||||
source=source, document_type=document_type, seen_at__lt=seen_at
|
||||
).update(missing_from_source=True)
|
||||
return count
|
||||
@ -35,6 +35,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class MoySkladSyncStats:
|
||||
projects: int = 0
|
||||
project_payments: int = 0
|
||||
processed: int = 0
|
||||
created_products: int = 0
|
||||
matched_products: int = 0
|
||||
@ -571,6 +573,8 @@ def normalize_moysklad_document(
|
||||
document_type: str,
|
||||
channel_names: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
from commerce.services.moysklad_projects import project_reference
|
||||
|
||||
source = connection.source
|
||||
source_id = _external_id(row)
|
||||
if not source_id:
|
||||
@ -588,6 +592,7 @@ def normalize_moysklad_document(
|
||||
)
|
||||
occurred_at = _source_updated_at({"updated": row.get("moment")}) or timezone.now()
|
||||
state = row.get("state") if isinstance(row.get("state"), dict) else {}
|
||||
agent = row.get("agent") if isinstance(row.get("agent"), dict) else {}
|
||||
sale, _ = Sale.objects.update_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
@ -596,6 +601,9 @@ def normalize_moysklad_document(
|
||||
"event_type": event_type,
|
||||
"number": str(row.get("name") or "")[:128],
|
||||
"channel": _document_channel(row, channel_names),
|
||||
"customer_external_id": _reference_external_id(agent)[:255],
|
||||
"customer_name": str(agent.get("name") or "")[:255],
|
||||
"project": project_reference(source, row.get("project")),
|
||||
"status": str(state.get("name") or "")[:128],
|
||||
"counts_as_sale": bool(row.get("applicable", True)),
|
||||
"currency": _document_currency(row),
|
||||
@ -839,6 +847,11 @@ def sync_moysklad_connection(
|
||||
run_id: str = "",
|
||||
task_id: str = "",
|
||||
) -> MoySkladSyncStats:
|
||||
from commerce.services.moysklad_projects import (
|
||||
sync_project_catalog,
|
||||
sync_project_payments,
|
||||
)
|
||||
|
||||
credentials = decrypt_credentials(connection)
|
||||
stats = MoySkladSyncStats()
|
||||
captured_at = captured_at or timezone.now()
|
||||
@ -855,6 +868,20 @@ def sync_moysklad_connection(
|
||||
with MoySkladClient(
|
||||
auth_type=connection.auth_type, credentials=credentials
|
||||
) as client:
|
||||
report(IntegrationSyncStage.PROJECTS, 0)
|
||||
stats.projects = sync_project_catalog(connection.source, client)
|
||||
report(IntegrationSyncStage.PROJECTS, stats.projects, stats.projects)
|
||||
report(IntegrationSyncStage.PROJECT_PAYMENTS, 0)
|
||||
stats.project_payments = sync_project_payments(
|
||||
connection.source,
|
||||
client,
|
||||
progress=lambda count: report(IntegrationSyncStage.PROJECT_PAYMENTS, count),
|
||||
)
|
||||
report(
|
||||
IntegrationSyncStage.PROJECT_PAYMENTS,
|
||||
stats.project_payments,
|
||||
stats.project_payments,
|
||||
)
|
||||
image_rows = _sync_catalog_stage(
|
||||
connection,
|
||||
client,
|
||||
|
||||
1
backend/finance/analytics/__init__.py
Normal file
1
backend/finance/analytics/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Management reporting over commerce, payroll and explicitly recorded facts."""
|
||||
103
backend/finance/analytics/models.py
Normal file
103
backend/finance/analytics/models.py
Normal file
@ -0,0 +1,103 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ReportingEntry(models.Model):
|
||||
"""Supplementary, documented facts absent from the operational sources.
|
||||
|
||||
Purchases and cash movements are not automatically expensed again in profit:
|
||||
sales already carry their cost of goods. Corrections retain the audit trail.
|
||||
"""
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
PRODUCTION = "production", "Выпуск продукции"
|
||||
PURCHASE = "purchase", "Закупка для производства"
|
||||
TAX = "tax", "Уплаченные налоги сотрудника"
|
||||
OVERHEAD = "overhead", "Общие расходы"
|
||||
CASH_IN = "cash_in", "Поступление денег"
|
||||
CASH_OUT = "cash_out", "Выплата денег"
|
||||
|
||||
kind = models.CharField(max_length=16, choices=Kind.choices)
|
||||
date = models.DateField("Дата факта", db_index=True)
|
||||
reference = models.CharField("Документ / уникальный номер", max_length=200)
|
||||
description = models.CharField("Описание", max_length=500, blank=True)
|
||||
project = models.ForeignKey(
|
||||
"commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"commerce.Product", null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
employee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
quantity = models.DecimalField(
|
||||
max_digits=18,
|
||||
decimal_places=4,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal("0"))],
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=18,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(Decimal("0"))],
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3, default="RUB", validators=[RegexValidator(r"^[A-Z]{3}$")]
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="reporting_entries_created",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
voided_at = models.DateTimeField(null=True, blank=True)
|
||||
voided_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="reporting_entries_voided",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date", "-pk"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["kind", "reference"], name="reporting_entry_reference_unique"
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(amount__gte=0, quantity__gte=0),
|
||||
name="reporting_entry_nonnegative",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
models.Q(
|
||||
kind="production",
|
||||
product__isnull=False,
|
||||
employee__isnull=True,
|
||||
quantity__gt=0,
|
||||
amount=0,
|
||||
)
|
||||
| models.Q(kind="purchase", employee__isnull=True, amount__gt=0)
|
||||
| models.Q(
|
||||
kind="tax",
|
||||
employee__isnull=False,
|
||||
product__isnull=True,
|
||||
amount__gt=0,
|
||||
quantity=0,
|
||||
)
|
||||
| models.Q(
|
||||
kind__in=["overhead", "cash_in", "cash_out"],
|
||||
product__isnull=True,
|
||||
employee__isnull=True,
|
||||
amount__gt=0,
|
||||
quantity=0,
|
||||
)
|
||||
),
|
||||
name="reporting_entry_kind_fields",
|
||||
),
|
||||
]
|
||||
235
backend/finance/analytics/people.py
Normal file
235
backend/finance/analytics/people.py
Normal file
@ -0,0 +1,235 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Subquery
|
||||
from django.utils import timezone
|
||||
|
||||
from attendance.models import AttendanceRecord
|
||||
from employees.models import Employee, EmployeeAction
|
||||
from employees.services.personnel_state import (
|
||||
calculate_employee_tenure_days,
|
||||
resolve_employee_personnel_state,
|
||||
)
|
||||
from finance.enums import PayrollRunStatus
|
||||
from finance.models import PayrollRun, PayrollStatement
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from .sales import ZERO, months
|
||||
|
||||
|
||||
def mean(values):
|
||||
return sum(values, ZERO) / len(values) if values else None
|
||||
|
||||
|
||||
def absence_days(employee, actions, start, end):
|
||||
vacation = sick = 0
|
||||
day = start
|
||||
while day <= end:
|
||||
status = (
|
||||
resolve_employee_personnel_state(employee, day, actions=actions).status
|
||||
if actions
|
||||
else "normal"
|
||||
)
|
||||
vacation += int(status == "on_leave")
|
||||
sick += int(status == "on_sick_leave")
|
||||
day += timedelta(days=1)
|
||||
return vacation, sick
|
||||
|
||||
|
||||
def people_report(start, end, currency):
|
||||
employees = list(
|
||||
Employee.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"actions",
|
||||
queryset=EmployeeAction.objects.select_related(
|
||||
"source_request"
|
||||
).order_by("date", "pk"),
|
||||
)
|
||||
).order_by("last_name", "first_name", "pk")
|
||||
)
|
||||
latest_run = (
|
||||
PayrollRun.objects.filter(
|
||||
period_id=OuterRef("run__period_id"), status=PayrollRunStatus.PUBLISHED
|
||||
)
|
||||
.order_by("-revision")
|
||||
.values("pk")[:1]
|
||||
)
|
||||
statements = PayrollStatement.objects.filter(
|
||||
run_id=Subquery(latest_run),
|
||||
currency=currency,
|
||||
run__period__date_from__gte=start,
|
||||
run__period__date_to__lte=end,
|
||||
).select_related("run__period")
|
||||
pay = defaultdict(lambda: defaultdict(lambda: ZERO))
|
||||
for statement in statements:
|
||||
month = statement.run.period.date_to.strftime("%Y-%m")
|
||||
pay[statement.employee_id][month] += statement.gross_total
|
||||
|
||||
attendance = defaultdict(dict)
|
||||
for record in AttendanceRecord.objects.filter(
|
||||
date__range=(start, end), analysis_run__status="success"
|
||||
):
|
||||
attendance[record.employee_id][record.date] = record
|
||||
taxes = defaultdict(lambda: ZERO)
|
||||
tax_counts = defaultdict(int)
|
||||
for entry in ReportingEntry.objects.filter(
|
||||
kind="tax", date__range=(start, end), currency=currency, voided_at__isnull=True
|
||||
):
|
||||
taxes[entry.employee_id] += entry.amount
|
||||
tax_counts[entry.employee_id] += 1
|
||||
|
||||
rows = []
|
||||
month_keys = list(months(start, end))
|
||||
comparison_months = sorted(
|
||||
{month for employee_pay in pay.values() for month in employee_pay}
|
||||
)
|
||||
demographic = {month: [] for month in month_keys}
|
||||
for employee in employees:
|
||||
actions = list(employee.actions.all())
|
||||
tenure = calculate_employee_tenure_days(employee, end, actions=actions)
|
||||
had_future_hire = any(
|
||||
a.action in ["hired", "rehired"] and timezone.localtime(a.date).date() > end
|
||||
for a in actions
|
||||
)
|
||||
state = resolve_employee_personnel_state(employee, end, actions=actions)
|
||||
employed = (tenure is not None and state.status != "dismissed") or (
|
||||
tenure is None and employee.is_active and not had_future_hire
|
||||
)
|
||||
salary = pay[employee.pk]
|
||||
records = attendance[employee.pk]
|
||||
employed_at_start = (
|
||||
calculate_employee_tenure_days(employee, start, actions=actions) is not None
|
||||
and resolve_employee_personnel_state(
|
||||
employee, start, actions=actions
|
||||
).status
|
||||
!= "dismissed"
|
||||
)
|
||||
worked_during_period = (tenure or 0) > (
|
||||
calculate_employee_tenure_days(employee, start, actions=actions) or 0
|
||||
)
|
||||
if (
|
||||
not employed
|
||||
and not employed_at_start
|
||||
and not worked_during_period
|
||||
and not salary
|
||||
and not records
|
||||
and not tax_counts[employee.pk]
|
||||
):
|
||||
continue
|
||||
vacation, sick = absence_days(employee, actions, start, end)
|
||||
age = None
|
||||
if employee.birth_date and employee.birth_date <= end:
|
||||
born = employee.birth_date
|
||||
age = end.year - born.year - ((end.month, end.day) < (born.month, born.day))
|
||||
first = salary.get(comparison_months[0]) if comparison_months else None
|
||||
last = salary.get(comparison_months[-1]) if comparison_months else None
|
||||
growth = (
|
||||
(last / first - 1) * 100
|
||||
if len(comparison_months) > 1 and first and last is not None
|
||||
else None
|
||||
)
|
||||
row = {
|
||||
"id": employee.pk,
|
||||
"name": " ".join(
|
||||
filter(
|
||||
None, [employee.last_name, employee.first_name, employee.patronymic]
|
||||
)
|
||||
),
|
||||
"employed": employed,
|
||||
"gender": employee.gender,
|
||||
"age": age,
|
||||
"tenure_days": tenure,
|
||||
"salary_total": sum(salary.values(), ZERO),
|
||||
"salary_months": len(salary),
|
||||
"average_salary": mean(list(salary.values())),
|
||||
"salary_growth_percent": growth,
|
||||
"worked_days": sum(
|
||||
1
|
||||
for r in records.values()
|
||||
if r.work_hours is not None and r.work_hours > 0 and not r.is_absent
|
||||
),
|
||||
"attendance_days": len(records),
|
||||
"vacation_days": vacation,
|
||||
"sick_days": sick,
|
||||
"tax_paid": taxes[employee.pk] if tax_counts[employee.pk] else None,
|
||||
"tax_payments": tax_counts[employee.pk],
|
||||
}
|
||||
rows.append(row)
|
||||
for month in month_keys:
|
||||
month_start = date.fromisoformat(month + "-01")
|
||||
as_of = min(
|
||||
end,
|
||||
(month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
- timedelta(days=1),
|
||||
)
|
||||
service = calculate_employee_tenure_days(employee, as_of, actions=actions)
|
||||
if (
|
||||
service is not None
|
||||
and resolve_employee_personnel_state(
|
||||
employee, as_of, actions=actions
|
||||
).status
|
||||
!= "dismissed"
|
||||
):
|
||||
born = employee.birth_date
|
||||
historical_age = (
|
||||
as_of.year
|
||||
- born.year
|
||||
- ((as_of.month, as_of.day) < (born.month, born.day))
|
||||
if born and born <= as_of
|
||||
else None
|
||||
)
|
||||
demographic[month].append((service, historical_age, employee.gender))
|
||||
|
||||
current = [r for r in rows if r["employed"]]
|
||||
comparable = [r for r in rows if r["salary_growth_percent"] is not None]
|
||||
ordered = sorted(comparable, key=lambda r: (r["salary_growth_percent"], r["id"]))
|
||||
monthly = []
|
||||
for month in month_keys:
|
||||
salaries = [p[month] for p in pay.values() if month in p]
|
||||
population = demographic[month]
|
||||
monthly.append(
|
||||
{
|
||||
"id": month,
|
||||
"average_salary": mean(salaries),
|
||||
"salary_total": sum(salaries, ZERO),
|
||||
"salary_employees": len(salaries),
|
||||
"headcount_with_history": len(population),
|
||||
"average_tenure_days": mean([Decimal(p[0]) for p in population]),
|
||||
"average_age": mean(
|
||||
[Decimal(p[1]) for p in population if p[1] is not None]
|
||||
),
|
||||
"men": sum(p[2] == 1 for p in population),
|
||||
"women": sum(p[2] == 2 for p in population),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"employees": rows,
|
||||
"monthly": monthly,
|
||||
"summary": {
|
||||
"growth_from": comparison_months[0] if comparison_months else None,
|
||||
"growth_to": comparison_months[-1] if comparison_months else None,
|
||||
"headcount": len(current),
|
||||
"men": sum(r["gender"] == 1 for r in current),
|
||||
"women": sum(r["gender"] == 2 for r in current),
|
||||
"unspecified_gender": sum(r["gender"] == 0 for r in current),
|
||||
"average_age": mean(
|
||||
[Decimal(r["age"]) for r in current if r["age"] is not None]
|
||||
),
|
||||
"average_tenure_days": mean(
|
||||
[
|
||||
Decimal(r["tenure_days"])
|
||||
for r in current
|
||||
if r["tenure_days"] is not None
|
||||
]
|
||||
),
|
||||
"missing_hire_dates": sum(r["tenure_days"] is None for r in current),
|
||||
"missing_birth_dates": sum(r["age"] is None for r in current),
|
||||
"average_salary": mean(
|
||||
[value for p in pay.values() for value in p.values()]
|
||||
),
|
||||
"salary_total": sum((sum(p.values(), ZERO) for p in pay.values()), ZERO),
|
||||
"tax_paid": sum(taxes.values(), ZERO) if any(tax_counts.values()) else None,
|
||||
"fastest_growth": ordered[-1]["id"] if ordered else None,
|
||||
"slowest_growth": ordered[0]["id"] if ordered else None,
|
||||
},
|
||||
}
|
||||
114
backend/finance/analytics/projects.py
Normal file
114
backend/finance/analytics/projects.py
Normal file
@ -0,0 +1,114 @@
|
||||
from collections import defaultdict
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.models import ExternalProject, ProjectPayment
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from finance.analytics.sales import ZERO, bounds, bucket, finish, months
|
||||
|
||||
|
||||
def payment_queryset(start, end, currency, source=None, project=None):
|
||||
lower, upper = bounds(start, end)
|
||||
qs = ProjectPayment.objects.filter(
|
||||
currency=currency,
|
||||
occurred_at__gte=lower,
|
||||
occurred_at__lt=upper,
|
||||
applicable=True,
|
||||
missing_from_source=False,
|
||||
).select_related("project", "source")
|
||||
if source:
|
||||
qs = qs.filter(source=source)
|
||||
if project is not None:
|
||||
qs = qs.filter(project_id=None if project == "unassigned" else project.pk)
|
||||
return qs
|
||||
|
||||
|
||||
def journal_queryset(start, end, currency, source, project):
|
||||
entries = ReportingEntry.objects.filter(
|
||||
date__range=(start, end),
|
||||
currency=currency,
|
||||
voided_at__isnull=True,
|
||||
kind__in=["cash_out", "overhead", "purchase"],
|
||||
).select_related("project")
|
||||
if source:
|
||||
entries = entries.filter(project__source=source)
|
||||
if project is not None:
|
||||
entries = entries.filter(
|
||||
project_id=None if project == "unassigned" else project.pk
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def projects_report(start, end, currency, sales, source=None, project=None):
|
||||
rows = {}
|
||||
|
||||
def row_for(key, name):
|
||||
if key not in rows:
|
||||
rows[key] = {
|
||||
**finish(bucket()),
|
||||
"id": key,
|
||||
"name": name,
|
||||
"paid": ZERO,
|
||||
"payment_count": 0,
|
||||
"manual_paid": ZERO,
|
||||
"overhead": ZERO,
|
||||
"purchases": ZERO,
|
||||
"archived": False,
|
||||
}
|
||||
return rows[key]
|
||||
|
||||
catalog = ExternalProject.objects.all()
|
||||
if source:
|
||||
catalog = catalog.filter(source=source)
|
||||
if project is not None:
|
||||
catalog = (
|
||||
catalog.none() if project == "unassigned" else catalog.filter(pk=project.pk)
|
||||
)
|
||||
for p in catalog:
|
||||
row_for(f"project:{p.pk}", p.name)["archived"] = p.archived
|
||||
for sale_row in sales["projects"]:
|
||||
row_for(sale_row["id"], sale_row["name"]).update(sale_row)
|
||||
monthly = {
|
||||
m: {"id": m, "name": m, "paid": ZERO, "manual_paid": ZERO}
|
||||
for m in months(start, end)
|
||||
}
|
||||
categories = defaultdict(lambda: {"paid": ZERO, "payment_count": 0})
|
||||
for p in payment_queryset(start, end, currency, source, project).iterator(
|
||||
chunk_size=500
|
||||
):
|
||||
row = row_for(
|
||||
f"project:{p.project_id}" if p.project_id else "unassigned",
|
||||
p.project.name if p.project else "Без проекта",
|
||||
)
|
||||
row["paid"] += p.amount
|
||||
row["payment_count"] += 1
|
||||
monthly[timezone.localtime(p.occurred_at).strftime("%Y-%m")]["paid"] += p.amount
|
||||
category = categories[p.expense_category or "Статья не указана"]
|
||||
category["paid"] += p.amount
|
||||
category["payment_count"] += 1
|
||||
for entry in journal_queryset(start, end, currency, source, project):
|
||||
row = row_for(
|
||||
f"project:{entry.project_id}" if entry.project_id else "unassigned",
|
||||
entry.project.name if entry.project else "Без проекта",
|
||||
)
|
||||
field = {
|
||||
"cash_out": "manual_paid",
|
||||
"overhead": "overhead",
|
||||
"purchase": "purchases",
|
||||
}[entry.kind]
|
||||
row[field] += entry.amount
|
||||
if entry.kind == "cash_out":
|
||||
monthly[entry.date.strftime("%Y-%m")]["manual_paid"] += entry.amount
|
||||
summary = {
|
||||
field: sum((r[field] for r in rows.values()), ZERO)
|
||||
for field in ["paid", "manual_paid", "overhead", "purchases", "payment_count"]
|
||||
}
|
||||
summary["unassigned_paid"] = rows.get("unassigned", {}).get("paid", ZERO)
|
||||
summary["project_count"] = catalog.count()
|
||||
return {
|
||||
"summary": summary,
|
||||
"rows": sorted(rows.values(), key=lambda r: (-r["paid"], r["name"])),
|
||||
"monthly": list(monthly.values()),
|
||||
"categories": [
|
||||
{"id": k, "name": k, **v} for k, v in sorted(categories.items())
|
||||
],
|
||||
}
|
||||
261
backend/finance/analytics/sales.py
Normal file
261
backend/finance/analytics/sales.py
Normal file
@ -0,0 +1,261 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.models import Cost, SaleLine
|
||||
from finance.analytics.models import ReportingEntry
|
||||
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
def bounds(start, end):
|
||||
tz = timezone.get_current_timezone()
|
||||
return (
|
||||
timezone.make_aware(datetime.combine(start, time.min), tz),
|
||||
timezone.make_aware(datetime.combine(end + timedelta(days=1), time.min), tz),
|
||||
)
|
||||
|
||||
|
||||
def bucket():
|
||||
return dict(
|
||||
quantity=ZERO,
|
||||
revenue=ZERO,
|
||||
cost_of_goods=ZERO,
|
||||
expenses=ZERO,
|
||||
missing_cost_lines=0,
|
||||
sale_lines=0,
|
||||
)
|
||||
|
||||
|
||||
def finish(row):
|
||||
row = dict(row)
|
||||
row["gross_profit"] = row["revenue"] - row["cost_of_goods"]
|
||||
row["contribution_profit"] = row["gross_profit"] - row["expenses"]
|
||||
row["profit"] = (
|
||||
row["contribution_profit"] if not row["missing_cost_lines"] else None
|
||||
)
|
||||
row["margin_percent"] = (
|
||||
row["profit"] / row["revenue"] * 100
|
||||
if row["profit"] is not None and row["revenue"] > 0
|
||||
else None
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def months(start, end):
|
||||
current = start.replace(day=1)
|
||||
while current <= end:
|
||||
yield current.strftime("%Y-%m")
|
||||
current = (current.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
|
||||
|
||||
def dimensions(labels, product, sale, channel=""):
|
||||
product_key = str(product.pk) if product else "unmatched"
|
||||
labels[product_key] = product.name if product else "Товар не сопоставлен"
|
||||
channel_key = channel or (sale.channel if sale else "") or "Не указан"
|
||||
customer_id = sale.customer_external_id if sale else ""
|
||||
customer_name = sale.customer_name if sale else ""
|
||||
customer_key = (
|
||||
f"{sale.source_id}:{customer_id or customer_name}"
|
||||
if sale and (customer_id or customer_name)
|
||||
else "unknown"
|
||||
)
|
||||
labels[customer_key] = customer_name or customer_id or "Клиент не указан"
|
||||
return product_key, channel_key, customer_key
|
||||
|
||||
|
||||
def project_costs(costs, project_id):
|
||||
if project_id is None:
|
||||
return costs.filter(project__isnull=True, sale_line__sale__project__isnull=True)
|
||||
return costs.filter(
|
||||
Q(project_id=project_id)
|
||||
| Q(project__isnull=True, sale_line__sale__project_id=project_id)
|
||||
)
|
||||
|
||||
|
||||
def sales_report(start, end, currency, source=None, product_ids=None, project=None):
|
||||
lower, upper = bounds(start, end)
|
||||
lines = SaleLine.objects.filter(
|
||||
sale__counts_as_sale=True,
|
||||
sale__currency=currency,
|
||||
sale__occurred_at__gte=lower,
|
||||
sale__occurred_at__lt=upper,
|
||||
).select_related("sale", "sale__project", "product")
|
||||
costs = (
|
||||
Cost.objects.filter(
|
||||
currency=currency, occurred_at__gte=lower, occurred_at__lt=upper
|
||||
)
|
||||
.select_related(
|
||||
"product",
|
||||
"sale_line__product",
|
||||
"sale_line__sale",
|
||||
"sale_line__sale__project",
|
||||
"project",
|
||||
)
|
||||
.exclude(sale_line__sale__counts_as_sale=False)
|
||||
)
|
||||
if source:
|
||||
lines = lines.filter(sale__source_id=source)
|
||||
costs = costs.filter(source_id=source)
|
||||
|
||||
if project is not None:
|
||||
project_id = None if project == "unassigned" else project.pk
|
||||
lines = lines.filter(sale__project_id=project_id)
|
||||
costs = project_costs(costs, project_id)
|
||||
|
||||
if product_ids is not None:
|
||||
lines = lines.filter(product_id__in=product_ids)
|
||||
costs = costs.filter(
|
||||
Q(sale_line__product_id__in=product_ids)
|
||||
| Q(sale_line__isnull=True, product_id__in=product_ids)
|
||||
)
|
||||
|
||||
total = bucket()
|
||||
groups = {
|
||||
name: defaultdict(bucket)
|
||||
for name in [
|
||||
"products",
|
||||
"channels",
|
||||
"customers",
|
||||
"product_channels",
|
||||
"monthly",
|
||||
"projects",
|
||||
]
|
||||
}
|
||||
for month in months(start, end):
|
||||
groups["monthly"][month]
|
||||
labels = {}
|
||||
unmatched = 0
|
||||
missing_customer = 0
|
||||
cost_types = defaultdict(lambda: ZERO)
|
||||
|
||||
def targets(product, sale, occurred_at, channel="", project=None):
|
||||
p, ch, customer = dimensions(labels, product, sale, channel)
|
||||
project = project or (sale.project if sale else None)
|
||||
project_key = f"project:{project.pk}" if project else "unassigned"
|
||||
labels[project_key] = project.name if project else "Без проекта"
|
||||
month = timezone.localtime(occurred_at).strftime("%Y-%m")
|
||||
return [
|
||||
total,
|
||||
groups["products"][p],
|
||||
groups["channels"][ch],
|
||||
groups["customers"][customer],
|
||||
groups["product_channels"][(p, ch)],
|
||||
groups["monthly"][month],
|
||||
groups["projects"][project_key],
|
||||
]
|
||||
|
||||
for line in lines.iterator(chunk_size=1000):
|
||||
sign = -1 if line.sale.event_type == "return" else 1
|
||||
# A zero without explicit provenance is unknown, not free inventory.
|
||||
missing = line.metadata.get("cost_source") == "unavailable" or (
|
||||
line.cost_amount == 0
|
||||
and line.metadata.get("cost_source") not in ["manual", "known_zero"]
|
||||
)
|
||||
for row in targets(line.product, line.sale, line.sale.occurred_at):
|
||||
row["quantity"] += sign * line.quantity
|
||||
row["revenue"] += sign * (line.gross_amount - line.discount_amount)
|
||||
row["cost_of_goods"] += sign * line.cost_amount
|
||||
row["missing_cost_lines"] += int(missing)
|
||||
row["sale_lines"] += 1
|
||||
unmatched += int(line.product_id is None)
|
||||
missing_customer += int(
|
||||
dimensions(labels, line.product, line.sale)[2] == "unknown"
|
||||
)
|
||||
|
||||
for cost in costs.iterator(chunk_size=1000):
|
||||
sale = cost.sale_line.sale if cost.sale_line_id else None
|
||||
product = cost.sale_line.product if cost.sale_line_id else cost.product
|
||||
# Attribute linked expenses to the same channel as their sale.
|
||||
for row in targets(
|
||||
product, sale, cost.occurred_at, "" if sale else cost.channel, cost.project
|
||||
):
|
||||
row["expenses"] += cost.amount
|
||||
cost_types[cost.cost_type] += cost.amount
|
||||
|
||||
result = {
|
||||
"summary": finish(total),
|
||||
"cost_breakdown": dict(cost_types),
|
||||
"quality": {
|
||||
"unmatched_product_lines": unmatched,
|
||||
"missing_customer_lines": missing_customer,
|
||||
"missing_cost_lines": total["missing_cost_lines"],
|
||||
},
|
||||
}
|
||||
result.update(grouped_rows(groups, labels))
|
||||
return result
|
||||
|
||||
|
||||
def grouped_rows(groups, labels):
|
||||
result = {}
|
||||
for dimension, rows in groups.items():
|
||||
output = []
|
||||
for key, row in rows.items():
|
||||
item = finish(row)
|
||||
if dimension == "product_channels":
|
||||
item.update(
|
||||
id=f"{key[0]}:{key[1]}",
|
||||
product_id=key[0],
|
||||
name=labels[key[0]],
|
||||
channel=key[1],
|
||||
)
|
||||
else:
|
||||
item.update(id=key, name=labels.get(key, key))
|
||||
output.append(item)
|
||||
result[dimension] = (
|
||||
sorted(output, key=lambda r: r["id"])
|
||||
if dimension == "monthly"
|
||||
else sorted(output, key=lambda r: (-r["revenue"], r["name"], r["id"]))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def production_report(start, end, currency, sales, project=None):
|
||||
entries = ReportingEntry.objects.filter(
|
||||
date__range=(start, end), voided_at__isnull=True
|
||||
).filter(Q(kind="production") | Q(currency=currency))
|
||||
if project is not None:
|
||||
entries = entries.filter(
|
||||
project_id=None if project == "unassigned" else project.pk
|
||||
)
|
||||
product_rows = {
|
||||
r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["products"]
|
||||
}
|
||||
monthly = {
|
||||
r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["monthly"]
|
||||
}
|
||||
totals = defaultdict(lambda: ZERO)
|
||||
counts = defaultdict(int)
|
||||
for entry in entries.select_related("product"):
|
||||
totals[entry.kind] += (
|
||||
entry.quantity if entry.kind == "production" else entry.amount
|
||||
)
|
||||
counts[entry.kind] += 1
|
||||
if entry.kind not in ["production", "purchase"]:
|
||||
continue
|
||||
field = "produced" if entry.kind == "production" else "purchases"
|
||||
value = entry.quantity if entry.kind == "production" else entry.amount
|
||||
month = entry.date.strftime("%Y-%m")
|
||||
monthly[month][field] += value
|
||||
key = str(entry.product_id) if entry.product_id else "unallocated"
|
||||
if key not in product_rows:
|
||||
product_rows[key] = {
|
||||
**finish(bucket()),
|
||||
"id": key,
|
||||
"name": entry.product.name
|
||||
if entry.product
|
||||
else "Закупки без привязки к товару",
|
||||
"produced": ZERO,
|
||||
"purchases": ZERO,
|
||||
}
|
||||
product_rows[key][field] += value
|
||||
return {
|
||||
"products": list(product_rows.values()),
|
||||
"monthly": list(monthly.values()),
|
||||
"totals": {kind: totals[kind] for kind in ReportingEntry.Kind.values},
|
||||
"record_counts": {kind: counts[kind] for kind in ReportingEntry.Kind.values},
|
||||
"product_channels": sales["product_channels"],
|
||||
}
|
||||
174
backend/finance/migrations/0032_reportingentry.py
Normal file
174
backend/finance/migrations/0032_reportingentry.py
Normal file
@ -0,0 +1,174 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 06:31
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("commerce", "0012_sale_customer_external_id_sale_customer_name"),
|
||||
("finance", "0031_payrolltablereviewmark"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReportingEntry",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"kind",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("production", "Выпуск продукции"),
|
||||
("purchase", "Закупка для производства"),
|
||||
("tax", "Уплаченные налоги сотрудника"),
|
||||
("overhead", "Общие расходы"),
|
||||
("cash_in", "Поступление денег"),
|
||||
("cash_out", "Выплата денег"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("date", models.DateField(db_index=True, verbose_name="Дата факта")),
|
||||
(
|
||||
"reference",
|
||||
models.CharField(
|
||||
max_length=200, verbose_name="Документ / уникальный номер"
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.CharField(
|
||||
blank=True, max_length=500, verbose_name="Описание"
|
||||
),
|
||||
),
|
||||
(
|
||||
"quantity",
|
||||
models.DecimalField(
|
||||
decimal_places=4,
|
||||
default=0,
|
||||
max_digits=18,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(Decimal("0"))
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=18,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(Decimal("0"))
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
default="RUB",
|
||||
max_length=3,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("^[A-Z]{3}$")
|
||||
],
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("voided_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="reporting_entries_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"employee",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"product",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="commerce.product",
|
||||
),
|
||||
),
|
||||
(
|
||||
"voided_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="reporting_entries_voided",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-date", "-pk"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("kind", "reference"),
|
||||
name="reporting_entry_reference_unique",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(("amount__gte", 0), ("quantity__gte", 0)),
|
||||
name="reporting_entry_nonnegative",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(
|
||||
("amount", 0),
|
||||
("employee__isnull", True),
|
||||
("kind", "production"),
|
||||
("product__isnull", False),
|
||||
("quantity__gt", 0),
|
||||
),
|
||||
models.Q(
|
||||
("amount__gt", 0),
|
||||
("employee__isnull", True),
|
||||
("kind", "purchase"),
|
||||
),
|
||||
models.Q(
|
||||
("amount__gt", 0),
|
||||
("employee__isnull", False),
|
||||
("kind", "tax"),
|
||||
("product__isnull", True),
|
||||
("quantity", 0),
|
||||
),
|
||||
models.Q(
|
||||
("amount__gt", 0),
|
||||
("employee__isnull", True),
|
||||
("kind__in", ["overhead", "cash_in", "cash_out"]),
|
||||
("product__isnull", True),
|
||||
("quantity", 0),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="reporting_entry_kind_fields",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/finance/migrations/0033_reportingentry_project.py
Normal file
20
backend/finance/migrations/0033_reportingentry_project.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.4 on 2026-09-07 07:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('commerce', '0014_externalproject_projectpayment_and_more'),
|
||||
('finance', '0032_reportingentry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reportingentry',
|
||||
name='project',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'),
|
||||
),
|
||||
]
|
||||
@ -26,6 +26,7 @@ from .enums import (
|
||||
PayrollRunStatus,
|
||||
VacationPayInstructionStatus,
|
||||
)
|
||||
from .analytics.models import ReportingEntry # noqa: F401
|
||||
|
||||
currency_validator = RegexValidator(
|
||||
regex=r"^[A-Z]{3}$",
|
||||
|
||||
@ -442,16 +442,17 @@ class TestSearchAPI:
|
||||
assert employees
|
||||
assert employees[0]["image"].endswith("/users/avatars/search-test.jpg")
|
||||
|
||||
def test_search_includes_products(self, api_client):
|
||||
"""Глобальный поиск охватывает каталог товаров."""
|
||||
product = Product.objects.create(
|
||||
@pytest.mark.parametrize("model_type", ["", "product"])
|
||||
def test_search_hides_deferred_products(self, api_client, model_type):
|
||||
"""Отложенный модуль не раскрывается через поиск и его счётчики."""
|
||||
Product.objects.create(
|
||||
sku="SEARCH-SKU-001",
|
||||
name="Уникальный поисковый товар",
|
||||
barcode="4600000000001",
|
||||
)
|
||||
call_command("buildwatson", verbosity=0)
|
||||
|
||||
response = api_client.get("/api/v1/search/?q=товар")
|
||||
response = api_client.get("/api/v1/search/", {"q": "товар", "type": model_type})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
products = [
|
||||
@ -459,10 +460,10 @@ class TestSearchAPI:
|
||||
for item in response.data["results"]
|
||||
if item["model_name"] == "product"
|
||||
]
|
||||
matching_product = next(
|
||||
item for item in products if item["object_id"] == str(product.id)
|
||||
)
|
||||
assert matching_product["meta"]["sku"] == "SEARCH-SKU-001"
|
||||
assert products == []
|
||||
assert "product" not in response.data["counts"]
|
||||
assert "product" not in response.data["hidden_counts"]
|
||||
assert response.data["total"] == 0
|
||||
|
||||
def test_search_counts_inaccessible_task_boards(
|
||||
self, api_client, regular_user, make_user
|
||||
|
||||
137
backend/tests/commerce/test_admin.py
Normal file
137
backend/tests/commerce/test_admin.py
Normal file
@ -0,0 +1,137 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladError
|
||||
from commerce.models import IntegrationConnection, IntegrationSyncRun, SourceSystem
|
||||
from commerce.services.credentials import decrypt_credentials, encrypt_credentials
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(client, user_factory):
|
||||
client.force_login(user_factory(staff=True, superuser=True))
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connection():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
return IntegrationConnection.objects.create(
|
||||
source=source, provider="moysklad", auth_type="token", status="ready",
|
||||
encrypted_credentials=encrypt_credentials({"token": "existing-test-token"}),
|
||||
)
|
||||
|
||||
|
||||
def connection_data(**overrides):
|
||||
return {"provider": "moysklad", "auth_type": "token", "auto_sync": "on",
|
||||
"sync_interval_minutes": "15", **overrides}
|
||||
|
||||
|
||||
def action(client, connection, name):
|
||||
return client.post(reverse("admin:commerce_integrationconnection_changelist"), {
|
||||
"action": name, "_selected_action": [str(connection.pk)],
|
||||
})
|
||||
|
||||
|
||||
def test_admin_creates_encrypted_connection_and_preserves_blank_credentials(admin_client):
|
||||
response = admin_client.post(
|
||||
reverse("admin:commerce_integrationconnection_add"),
|
||||
connection_data(token="admin-test-token"),
|
||||
)
|
||||
assert response.status_code == 302
|
||||
connection = IntegrationConnection.objects.get()
|
||||
assert connection.source.code == "moysklad"
|
||||
assert connection.created_by.is_superuser
|
||||
assert "admin-test-token" not in connection.encrypted_credentials
|
||||
assert decrypt_credentials(connection) == {"token": "admin-test-token"}
|
||||
encrypted = connection.encrypted_credentials
|
||||
url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk])
|
||||
page = admin_client.get(url)
|
||||
assert page.status_code == 200
|
||||
assert b"admin-test-token" not in page.content
|
||||
assert encrypted.encode() not in page.content
|
||||
response = admin_client.post(url, connection_data(sync_interval_minutes="30"))
|
||||
assert response.status_code == 302
|
||||
connection.refresh_from_db()
|
||||
assert connection.encrypted_credentials == encrypted
|
||||
assert connection.sync_interval_minutes == 30
|
||||
assert connection.next_sync_at is not None
|
||||
|
||||
|
||||
def test_admin_requires_complete_new_auth_and_preserves_password_spaces(admin_client, connection):
|
||||
url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk])
|
||||
response = admin_client.post(url, connection_data(auth_type="basic"))
|
||||
assert response.status_code == 200
|
||||
assert {"login", "password"} <= response.context["adminform"].form.errors.keys()
|
||||
connection.refresh_from_db()
|
||||
assert connection.auth_type == "token"
|
||||
response = admin_client.post(url, connection_data(
|
||||
auth_type="basic", login="admin@test", password=" password with spaces ",
|
||||
))
|
||||
assert response.status_code == 302
|
||||
connection.refresh_from_db()
|
||||
assert decrypt_credentials(connection) == {"login": "admin@test", "password": " password with spaces "}
|
||||
assert connection.status == "disconnected"
|
||||
|
||||
|
||||
def test_admin_rejects_basic_auth_for_wildberries(admin_client):
|
||||
response = admin_client.post(reverse("admin:commerce_integrationconnection_add"),
|
||||
connection_data(provider="wildberries", auth_type="basic", login="test", password="test"))
|
||||
assert response.status_code == 200
|
||||
assert "auth_type" in response.context["adminform"].form.errors
|
||||
assert not IntegrationConnection.objects.exists()
|
||||
|
||||
|
||||
def test_admin_test_action_updates_state_and_reports_failure(admin_client, connection):
|
||||
with patch("commerce.admin.test_moysklad_connection", return_value={
|
||||
"account_name": "Test company", "account_id": "test-account",
|
||||
}):
|
||||
assert action(admin_client, connection, "test_connections").status_code == 302
|
||||
connection.refresh_from_db()
|
||||
assert connection.status == "ready"
|
||||
assert connection.account_name == "Test company"
|
||||
assert connection.last_tested_at is not None
|
||||
assert connection.source.metadata["account_id"] == "test-account"
|
||||
with patch("commerce.admin.test_moysklad_connection", side_effect=MoySkladError("Test failure")):
|
||||
assert action(admin_client, connection, "test_connections").status_code == 302
|
||||
connection.refresh_from_db()
|
||||
assert connection.status == "error"
|
||||
assert connection.last_error == "Test failure"
|
||||
|
||||
|
||||
def test_admin_sync_enqueues_once_and_blocks_editing_active_connection(
|
||||
admin_client, connection, django_capture_on_commit_callbacks,
|
||||
):
|
||||
with patch("commerce.tasks.sync_connection_run.apply_async") as publish, \
|
||||
django_capture_on_commit_callbacks(execute=True):
|
||||
assert action(admin_client, connection, "sync_connections").status_code == 302
|
||||
assert action(admin_client, connection, "sync_connections").status_code == 302
|
||||
publish.assert_called_once()
|
||||
assert IntegrationSyncRun.objects.filter(connection=connection).count() == 1
|
||||
connection.refresh_from_db()
|
||||
assert connection.status == "queued"
|
||||
url = reverse("admin:commerce_integrationconnection_change", args=[connection.pk])
|
||||
response = admin_client.post(url, connection_data(token="replacement"))
|
||||
assert response.status_code == 200
|
||||
assert response.context["adminform"].form.non_field_errors()
|
||||
connection.refresh_from_db()
|
||||
assert decrypt_credentials(connection) == {"token": "existing-test-token"}
|
||||
|
||||
|
||||
def test_view_only_staff_cannot_run_admin_actions(client, user_factory, connection):
|
||||
user = user_factory(staff=True)
|
||||
user.user_permissions.add(Permission.objects.get(
|
||||
content_type__app_label="commerce", codename="view_integrationconnection",
|
||||
))
|
||||
client.force_login(user)
|
||||
assert client.get(reverse(
|
||||
"admin:commerce_integrationconnection_change", args=[connection.pk],
|
||||
)).status_code == 200
|
||||
with patch("commerce.admin.enqueue_connection_sync") as enqueue:
|
||||
action(client, connection, "sync_connections")
|
||||
enqueue.assert_not_called()
|
||||
assert not IntegrationSyncRun.objects.exists()
|
||||
327
backend/tests/finance/test_analytics.py
Normal file
327
backend/tests/finance/test_analytics.py
Normal file
@ -0,0 +1,327 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem
|
||||
from finance.analytics.models import ReportingEntry
|
||||
from finance.analytics.people import people_report
|
||||
from finance.analytics.sales import production_report, sales_report
|
||||
from finance.models import FinanceSettings, PayrollPeriod, PayrollRun, PayrollStatement
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def moment(day):
|
||||
return timezone.make_aware(datetime.fromisoformat(day + "T12:00:00"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def facts():
|
||||
source = SourceSystem.objects.create(code="test-analytics", name="Test")
|
||||
product = Product.objects.create(sku="ANALYTICS", name="Test product")
|
||||
|
||||
def line(ref, revenue="100", cost="40", day="2026-01-10", **kwargs):
|
||||
sale = Sale.objects.create(
|
||||
source=source,
|
||||
external_id=ref,
|
||||
occurred_at=moment(day),
|
||||
channel="direct",
|
||||
customer_external_id="client-1",
|
||||
customer_name="Client",
|
||||
**kwargs,
|
||||
)
|
||||
return SaleLine.objects.create(
|
||||
sale=sale,
|
||||
product=product,
|
||||
external_id="1",
|
||||
quantity=1,
|
||||
gross_amount=Decimal(revenue),
|
||||
cost_amount=Decimal(cost),
|
||||
)
|
||||
|
||||
return source, product, line
|
||||
|
||||
|
||||
def test_sales_reconcile_refunds_expenses_currency_and_date_boundaries(facts):
|
||||
source, product, line = facts
|
||||
sale_line = line("sale")
|
||||
line("return", revenue="25", cost="10", event_type="return")
|
||||
line("cancelled", counts_as_sale=False)
|
||||
line("usd", currency="USD")
|
||||
line("outside", day="2026-02-01")
|
||||
Cost.objects.create(
|
||||
source=source,
|
||||
product=product,
|
||||
sale_line=sale_line,
|
||||
external_id="fee",
|
||||
amount=5,
|
||||
cost_type="commission",
|
||||
occurred_at=moment("2026-01-31"),
|
||||
)
|
||||
Cost.objects.create(
|
||||
source=source,
|
||||
product=product,
|
||||
amount=2,
|
||||
cost_type="advertising",
|
||||
occurred_at=moment("2026-01-15"),
|
||||
)
|
||||
report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB")
|
||||
assert report["summary"]["revenue"] == 75
|
||||
assert report["summary"]["cost_of_goods"] == 30
|
||||
assert report["summary"]["profit"] == 38
|
||||
assert report["summary"]["expenses"] == 7
|
||||
for dimension in [
|
||||
"products",
|
||||
"channels",
|
||||
"customers",
|
||||
"product_channels",
|
||||
"monthly",
|
||||
]:
|
||||
assert sum(row["profit"] for row in report[dimension]) == 38
|
||||
assert next(r for r in report["customers"] if r["id"] == "unknown")["expenses"] == 2
|
||||
assert report["quality"]["missing_customer_lines"] == 0
|
||||
|
||||
|
||||
def test_unknown_cost_is_not_reported_as_profit_and_source_filter(facts):
|
||||
source, _, line = facts
|
||||
line("missing", cost="0")
|
||||
report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", source)
|
||||
assert report["summary"]["profit"] is None
|
||||
assert report["summary"]["missing_cost_lines"] == 1
|
||||
other = SourceSystem.objects.create(code="empty", name="Empty")
|
||||
assert (
|
||||
sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", other)["summary"][
|
||||
"revenue"
|
||||
]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_report_api_auth_allowlist_validation_and_no_store(api_client, user_factory):
|
||||
url = reverse("api:v1:finance-payroll:analytics-report")
|
||||
assert api_client.get(url).status_code == 401
|
||||
api_client.force_authenticate(user=user_factory())
|
||||
assert api_client.get(url).status_code == 403
|
||||
staff = user_factory(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
params = {"date_from": "2026-01-01", "date_to": "2026-01-31", "currency": "RUB"}
|
||||
response = api_client.get(url, params)
|
||||
assert response.status_code == 200
|
||||
assert response.data["sales"]["summary"]["revenue"] == "0.0000"
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
for changes in [
|
||||
{"date_from": "bad"},
|
||||
{"date_to": "2025-01-01"},
|
||||
{"date_to": "2028-01-01"},
|
||||
{"currency": "rub"},
|
||||
{"section": "bad"},
|
||||
{"source": "nope"},
|
||||
]:
|
||||
assert api_client.get(url, {**params, **changes}).status_code == 400
|
||||
FinanceSettings.get_solo().management_users.add(user_factory(staff=True))
|
||||
assert api_client.get(url, params).status_code == 403
|
||||
|
||||
|
||||
def test_journal_validates_duplicates_and_void_preserves_history(
|
||||
api_client, user_factory, facts
|
||||
):
|
||||
_, product, _ = facts
|
||||
staff = user_factory(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
url = reverse("api:v1:finance-payroll:analytics-entries")
|
||||
payload = {
|
||||
"kind": "production",
|
||||
"reference": "output-1",
|
||||
"date": "2026-01-10",
|
||||
"quantity": "3",
|
||||
"product": str(product.pk),
|
||||
}
|
||||
for change in [
|
||||
{"product": None},
|
||||
{"quantity": "-1"},
|
||||
{"amount": "10"},
|
||||
{"date": "2099-01-01"},
|
||||
]:
|
||||
assert (
|
||||
api_client.post(url, {**payload, **change}, format="json").status_code
|
||||
== 400
|
||||
)
|
||||
response = api_client.post(url, payload, format="json")
|
||||
assert response.status_code == 201, response.data
|
||||
assert api_client.post(url, payload, format="json").status_code == 400
|
||||
start, end = date(2026, 1, 1), date(2026, 1, 31)
|
||||
sales = sales_report(start, end, "RUB")
|
||||
assert production_report(start, end, "RUB", sales)["totals"]["production"] == 3
|
||||
void_url = reverse(
|
||||
"api:v1:finance-payroll:analytics-entry-void", args=[response.data["id"]]
|
||||
)
|
||||
assert api_client.post(void_url).status_code == 200
|
||||
assert api_client.post(void_url).status_code == 200
|
||||
assert ReportingEntry.objects.count() == 1
|
||||
assert production_report(start, end, "RUB", sales)["totals"]["production"] == 0
|
||||
|
||||
|
||||
def test_payroll_uses_published_revision_month_sums_and_actual_tax(user_factory):
|
||||
author, approver, employee = user_factory(), user_factory(), user_factory()
|
||||
for index, (start, end, gross) in enumerate(
|
||||
[
|
||||
("2026-01-01", "2026-01-15", 40),
|
||||
("2026-01-16", "2026-01-31", 60),
|
||||
("2026-02-01", "2026-02-28", 120),
|
||||
]
|
||||
):
|
||||
period = PayrollPeriod.objects.create(
|
||||
code=f"a-{index}", date_from=start, date_to=end, currency="RUB"
|
||||
)
|
||||
run = PayrollRun.objects.create(
|
||||
period=period,
|
||||
revision=1,
|
||||
status="published",
|
||||
requested_by=author,
|
||||
approved_by=approver,
|
||||
approved_at=timezone.now(),
|
||||
published_by=approver,
|
||||
published_at=timezone.now(),
|
||||
gross_total=gross,
|
||||
deduction_total=0,
|
||||
payable_total=gross,
|
||||
)
|
||||
PayrollStatement.objects.create(
|
||||
run=run,
|
||||
employee=employee,
|
||||
currency="RUB",
|
||||
gross_before_adjustments=gross,
|
||||
adjustment_total=0,
|
||||
gross_total=gross,
|
||||
deduction_total=0,
|
||||
net_pay=gross,
|
||||
payment_total=0,
|
||||
payable=gross,
|
||||
)
|
||||
# A new draft must not hide or double-count the published run.
|
||||
PayrollRun.objects.create(
|
||||
period=period,
|
||||
revision=2,
|
||||
requested_by=author,
|
||||
gross_total=999,
|
||||
deduction_total=0,
|
||||
payable_total=999,
|
||||
)
|
||||
ReportingEntry.objects.create(
|
||||
kind="tax",
|
||||
reference="tax-1",
|
||||
date=date(2026, 2, 10),
|
||||
employee=employee,
|
||||
amount=13,
|
||||
created_by=author,
|
||||
)
|
||||
report = people_report(date(2026, 1, 1), date(2026, 2, 28), "RUB")
|
||||
row = next(r for r in report["employees"] if r["id"] == employee.pk)
|
||||
assert row["salary_total"] == 220
|
||||
assert row["average_salary"] == 110
|
||||
assert row["salary_growth_percent"] == 20
|
||||
assert row["tax_paid"] == 13
|
||||
assert row["attendance_days"] == 0
|
||||
partial = people_report(date(2026, 1, 10), date(2026, 1, 31), "RUB")
|
||||
assert (
|
||||
next(r for r in partial["employees"] if r["id"] == employee.pk)["salary_total"]
|
||||
== 60
|
||||
)
|
||||
|
||||
|
||||
def test_personnel_history_leave_overlap_missing_tax_and_future_hire(user_factory):
|
||||
from employees.models import EmployeeAction
|
||||
|
||||
employee = user_factory()
|
||||
future = user_factory()
|
||||
EmployeeAction.objects.bulk_create(
|
||||
[
|
||||
EmployeeAction(
|
||||
employee=employee, action="hired", date=moment("2025-01-01")
|
||||
),
|
||||
EmployeeAction(
|
||||
employee=employee,
|
||||
action="on_leave",
|
||||
date=moment("2026-01-10"),
|
||||
date_to=date(2026, 1, 20),
|
||||
),
|
||||
EmployeeAction(
|
||||
employee=employee,
|
||||
action="on_sick_leave",
|
||||
date=moment("2026-01-15"),
|
||||
date_to=date(2026, 1, 17),
|
||||
),
|
||||
EmployeeAction(employee=future, action="hired", date=moment("2026-03-01")),
|
||||
]
|
||||
)
|
||||
report = people_report(date(2026, 1, 1), date(2026, 1, 31), "RUB")
|
||||
row = next(r for r in report["employees"] if r["id"] == employee.pk)
|
||||
assert row["vacation_days"] == 8
|
||||
assert row["sick_days"] == 3
|
||||
assert row["tenure_days"] == 395
|
||||
assert all(r["id"] != future.pk for r in report["employees"])
|
||||
assert report["summary"]["tax_paid"] is None
|
||||
assert report["summary"]["average_salary"] is None
|
||||
|
||||
|
||||
def test_customer_backfill_extracts_only_counterparty_and_is_idempotent(facts):
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
from commerce.models import SourceRecord
|
||||
|
||||
source, _, line = facts
|
||||
record = SourceRecord.objects.create(
|
||||
source=source,
|
||||
entity_type="demand",
|
||||
external_id="backfill",
|
||||
payload={
|
||||
"agent": {
|
||||
"meta": {"href": "https://example.test/counterparty/client-id"},
|
||||
"name": "Customer",
|
||||
}
|
||||
},
|
||||
)
|
||||
sale = line("backfill").sale
|
||||
sale.source_record = record
|
||||
sale.customer_external_id = ""
|
||||
sale.customer_name = ""
|
||||
sale.save()
|
||||
restore = import_module(
|
||||
"commerce.migrations.0013_restore_sale_customers"
|
||||
).restore_customers
|
||||
restore(apps, SimpleNamespace(connection=connection))
|
||||
restore(apps, SimpleNamespace(connection=connection))
|
||||
sale.refresh_from_db()
|
||||
assert sale.customer_external_id == "client-id"
|
||||
assert sale.customer_name == "Customer"
|
||||
|
||||
|
||||
def test_production_api_excludes_resold_products(api_client, user_factory, facts):
|
||||
_, product, line = facts
|
||||
staff = user_factory(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
line("manufactured")
|
||||
resold = Product.objects.create(sku="RESOLD", name="Resold")
|
||||
other = line("resold", revenue="999", cost="1")
|
||||
other.product = resold
|
||||
other.save()
|
||||
ReportingEntry.objects.create(
|
||||
kind="production",
|
||||
reference="previous-year",
|
||||
date=date(2025, 12, 1),
|
||||
product=product,
|
||||
quantity=10,
|
||||
created_by=staff,
|
||||
)
|
||||
response = api_client.get(
|
||||
reverse("api:v1:finance-payroll:analytics-report"),
|
||||
{"section": "production", "date_from": "2026-01-01", "date_to": "2026-01-31"},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
assert response.data["sales"]["summary"]["revenue"] == "100.0000"
|
||||
assert response.data["production"]["record_counts"]["production"] == 0
|
||||
300
backend/tests/finance/test_projects.py
Normal file
300
backend/tests/finance/test_projects.py
Normal file
@ -0,0 +1,300 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import Mock
|
||||
import httpx
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
|
||||
from commerce.models import (
|
||||
ExternalProject,
|
||||
ProjectPayment,
|
||||
Sale,
|
||||
SaleLine,
|
||||
Cost,
|
||||
SourceSystem,
|
||||
)
|
||||
from commerce.services.moysklad_projects import (
|
||||
normalize_project,
|
||||
normalize_project_payment,
|
||||
sync_project_payments,
|
||||
sync_project_catalog,
|
||||
restore_sale_projects,
|
||||
)
|
||||
from commerce.services.ingestion import ingest_source_record
|
||||
from finance.analytics.projects import projects_report
|
||||
from finance.analytics.sales import sales_report
|
||||
from finance.analytics.models import ReportingEntry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
START, END = date(2026, 1, 1), date(2026, 1, 31)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source():
|
||||
return SourceSystem.objects.create(code="projects-test", name="МойСклад")
|
||||
|
||||
|
||||
def payment(source, ref="payment1", **kwargs):
|
||||
row = {
|
||||
"id": ref,
|
||||
"moment": "2026-01-10 12:00:00",
|
||||
"sum": 10000,
|
||||
"applicable": True,
|
||||
"name": "Оплата",
|
||||
"project": {"id": "project1", "name": "Первый"},
|
||||
"expenseItem": {"name": "Материалы"},
|
||||
}
|
||||
row.update(kwargs)
|
||||
return normalize_project_payment(source, row, "paymentout", timezone.now())
|
||||
|
||||
|
||||
def test_project_payment_reimport_reassignment_and_cancel(source):
|
||||
p = payment(source)
|
||||
assert p.amount == Decimal("100")
|
||||
assert payment(source).pk == p.pk
|
||||
assert ProjectPayment.objects.count() == 1
|
||||
payment(source, project={"id": "project2", "name": "Второй"}, sum=15000)
|
||||
p.refresh_from_db()
|
||||
assert p.project.external_id == "project2" and p.amount == 150
|
||||
payment(source, project=None, applicable=False)
|
||||
p.refresh_from_db()
|
||||
assert p.project is None and not p.applicable
|
||||
assert p.source_record.payload["applicable"] is False
|
||||
|
||||
|
||||
def test_full_scan_marks_missing_but_failed_scan_preserves_history(source):
|
||||
old = payment(source)
|
||||
client = Mock()
|
||||
client.iter_project_data.side_effect = MoySkladError("offline")
|
||||
with pytest.raises(MoySkladError):
|
||||
sync_project_payments(source, client)
|
||||
old.refresh_from_db()
|
||||
assert not old.missing_from_source
|
||||
client.iter_project_data.side_effect = lambda *a, **kw: iter([])
|
||||
assert sync_project_payments(source, client) == 0
|
||||
old.refresh_from_db()
|
||||
assert old.missing_from_source
|
||||
payment(source)
|
||||
old.refresh_from_db()
|
||||
assert not old.missing_from_source
|
||||
|
||||
|
||||
def test_catalog_preserves_archive_and_source_identity(source):
|
||||
client = Mock()
|
||||
client.iter_project_data.side_effect = lambda kind, archived=False: iter(
|
||||
[
|
||||
{
|
||||
"id": "archived" if archived else "active",
|
||||
"name": "Одинаковое имя",
|
||||
"archived": archived,
|
||||
}
|
||||
]
|
||||
)
|
||||
assert sync_project_catalog(source, client) == 2
|
||||
assert sync_project_catalog(source, client) == 2
|
||||
assert ExternalProject.objects.count() == 2
|
||||
other = SourceSystem.objects.create(code="other", name="Other")
|
||||
normalize_project(other, {"id": "active", "name": "Other account"})
|
||||
assert ExternalProject.objects.count() == 3
|
||||
assert ExternalProject.objects.get(source=source, external_id="archived").archived
|
||||
|
||||
|
||||
def test_project_report_reconciles_currency_dates_unassigned_and_manual(
|
||||
source, user_factory
|
||||
):
|
||||
paid = payment(source)
|
||||
project = paid.project
|
||||
payment(source, ref="unassigned", project=None, sum=5000)
|
||||
payment(source, ref="cancelled", applicable=False)
|
||||
payment(source, ref="outside", moment="2026-02-01 12:00:00")
|
||||
payment(source, ref="foreign", rate={"currency": {"isoCode": "USD"}})
|
||||
sale = Sale.objects.create(
|
||||
source=source, project=project, external_id="sale", occurred_at=paid.occurred_at
|
||||
)
|
||||
line = SaleLine.objects.create(
|
||||
sale=sale, external_id="1", gross_amount=500, cost_amount=200, quantity=1
|
||||
)
|
||||
Cost.objects.create(
|
||||
source=source,
|
||||
sale_line=line,
|
||||
cost_type="delivery",
|
||||
amount=20,
|
||||
occurred_at=paid.occurred_at,
|
||||
)
|
||||
Cost.objects.create(
|
||||
source=source,
|
||||
project=project,
|
||||
cost_type="other",
|
||||
amount=30,
|
||||
occurred_at=paid.occurred_at,
|
||||
)
|
||||
user = user_factory()
|
||||
ReportingEntry.objects.create(
|
||||
kind="overhead",
|
||||
project=project,
|
||||
date=START,
|
||||
reference="overhead",
|
||||
amount=40,
|
||||
created_by=user,
|
||||
)
|
||||
ReportingEntry.objects.create(
|
||||
kind="cash_out",
|
||||
project=project,
|
||||
date=START,
|
||||
reference="manual",
|
||||
amount=10,
|
||||
created_by=user,
|
||||
)
|
||||
sales = sales_report(START, END, "RUB")
|
||||
report = projects_report(START, END, "RUB", sales)
|
||||
assert report["summary"]["paid"] == 150
|
||||
assert report["summary"]["unassigned_paid"] == 50
|
||||
assert (
|
||||
sum(r["paid"] for r in report["rows"])
|
||||
== sum(r["paid"] for r in report["categories"])
|
||||
== 150
|
||||
)
|
||||
assert sum(r["paid"] for r in report["monthly"]) == 150
|
||||
scoped_sales = sales_report(START, END, "RUB", project=project)
|
||||
assert (
|
||||
scoped_sales["summary"]["profit"] == 250
|
||||
) # cash payments are not expensed twice
|
||||
scoped = projects_report(START, END, "RUB", scoped_sales, project=project)
|
||||
assert scoped["summary"]["paid"] == 100
|
||||
assert scoped["summary"]["manual_paid"] == 10
|
||||
assert scoped["summary"]["overhead"] == 40
|
||||
assert (
|
||||
sales_report(START, END, "RUB", project="unassigned")["summary"]["expenses"]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_projects_api_permissions_filter_and_journal(api_client, user_factory, source):
|
||||
p = payment(source)
|
||||
url = reverse("api:v1:finance-payroll:analytics-report")
|
||||
payments_url = reverse("api:v1:finance-payroll:analytics-project-payments")
|
||||
assert api_client.get(payments_url).status_code == 401
|
||||
api_client.force_authenticate(user=user_factory())
|
||||
assert api_client.get(payments_url).status_code == 403
|
||||
api_client.force_authenticate(user=user_factory(staff=True))
|
||||
params = {
|
||||
"date_from": str(START),
|
||||
"date_to": str(END),
|
||||
"project": p.project_id,
|
||||
"section": "projects",
|
||||
}
|
||||
response = api_client.get(url, params)
|
||||
assert response.status_code == 200
|
||||
assert response.data["projects"]["summary"]["paid"] == "100.0000"
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
assert api_client.get(url, {**params, "project": "bad"}).status_code == 400
|
||||
other = SourceSystem.objects.create(code="other", name="Other")
|
||||
assert api_client.get(url, {**params, "source": other.pk}).status_code == 400
|
||||
response = api_client.get(payments_url, params)
|
||||
assert response.status_code == 200 and response.data["count"] == 1
|
||||
assert response.data["results"][0]["project_name"] == "Первый"
|
||||
assert api_client.post(payments_url, {}).status_code == 405
|
||||
entry_url = reverse("api:v1:finance-payroll:analytics-entries")
|
||||
assert (
|
||||
api_client.post(
|
||||
entry_url,
|
||||
{
|
||||
"kind": "overhead",
|
||||
"project": p.project_id,
|
||||
"date": "2026-01-01",
|
||||
"reference": "API",
|
||||
"amount": "5.00",
|
||||
},
|
||||
format="json",
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
assert api_client.get(entry_url, params).data["count"] == 1
|
||||
assert (
|
||||
api_client.get(entry_url, {**params, "project": "unassigned"}).data["count"]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_saved_sale_project_backfill_is_idempotent(source):
|
||||
record = ingest_source_record(
|
||||
source=source,
|
||||
entity_type="demand",
|
||||
external_id="old",
|
||||
payload={
|
||||
"project": {
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/project/project1"
|
||||
}
|
||||
}
|
||||
},
|
||||
).record
|
||||
sale = Sale.objects.create(
|
||||
source=source,
|
||||
source_record=record,
|
||||
external_id="old",
|
||||
occurred_at=timezone.now(),
|
||||
)
|
||||
assert restore_sale_projects(source) == 1
|
||||
assert restore_sale_projects(source) == 0
|
||||
sale.refresh_from_db()
|
||||
assert sale.project.external_id == "project1"
|
||||
|
||||
|
||||
def test_project_client_pagination_and_archive_filters():
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client._get = Mock(side_effect=[{"rows": [{"id": "1"}]}, {"rows": []}])
|
||||
assert list(client.iter_project_data("project", archived=True, limit=1)) == [
|
||||
{"id": "1"}
|
||||
]
|
||||
assert client._get.call_args_list[0].kwargs["params"]["filter"] == "archived=true"
|
||||
assert client._get.call_args_list[1].kwargs["params"]["offset"] == 1
|
||||
client._get = Mock(return_value={"rows": None})
|
||||
with pytest.raises(MoySkladError):
|
||||
list(client.iter_project_data("paymentout"))
|
||||
client.close()
|
||||
|
||||
|
||||
def test_transient_http_failure_retries_without_replaying_writes(monkeypatch):
|
||||
monkeypatch.setattr("commerce.integrations.moysklad.time.sleep", lambda _: None)
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
responses = iter([502, 429, 200])
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
next(responses), json={"rows": []}, request=request
|
||||
)
|
||||
),
|
||||
)
|
||||
assert list(client.iter_project_data("project")) == []
|
||||
client.close()
|
||||
|
||||
|
||||
def test_payment_references_are_cached_and_paths_stay_on_api():
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
external_id = "11111111-1111-1111-1111-111111111111"
|
||||
reference = {
|
||||
"meta": {
|
||||
"type": "expenseitem",
|
||||
"href": f"https://api.moysklad.ru/api/remap/1.2/entity/expenseitem/{external_id}",
|
||||
}
|
||||
}
|
||||
client._get = Mock(return_value={"id": external_id, "name": "Доставка"})
|
||||
for _ in range(3):
|
||||
assert (
|
||||
client._payment_references({"expenseItem": reference})["expenseItem"][
|
||||
"name"
|
||||
]
|
||||
== "Доставка"
|
||||
)
|
||||
client._get.assert_called_once_with(f"/entity/expenseitem/{external_id}")
|
||||
with pytest.raises(MoySkladError):
|
||||
client._payment_reference(
|
||||
{"meta": {"type": "../private", "href": "https://invalid/secret"}}
|
||||
)
|
||||
client.close()
|
||||
@ -12,6 +12,7 @@
|
||||
- [Scheduling module](../backend/scheduling/README.md)
|
||||
- [Procurement module](../backend/procurement/README.md)
|
||||
- [Finance module](../backend/finance/README.md)
|
||||
- [Аналитика компании](guides/COMPANY_ANALYTICS.md)
|
||||
- [Payroll core](../backend/payroll_core/README.md)
|
||||
|
||||
Интерактивные runtime-контракты:
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
# Commerce, МойСклад и маркетплейсы
|
||||
|
||||
Сводные отчёты продаж, сотрудников и производства описаны в
|
||||
[руководстве по аналитике компании](COMPANY_ANALYTICS.md).
|
||||
|
||||
Commerce-контур хранит нормализованные товары, внешние привязки, продажи,
|
||||
возвраты, расходы, остатки и локальные изображения товаров. Источником истины
|
||||
для структуры данных служат модели `backend/commerce/models/`, для HTTP API —
|
||||
@ -8,6 +11,26 @@ Commerce-контур хранит нормализованные товары,
|
||||
|
||||
## Импорт
|
||||
|
||||
Пользовательский раздел «Товары» временно скрыт для дальнейшей разработки:
|
||||
пункт меню и выдача товаров в глобальном поиске отключены, адреса `/products`
|
||||
и вложенные страницы перенаправляют в ленту. Код экранов, данные и HTTP API
|
||||
сохранены; финансовая аналитика продолжает использовать импортированные факты.
|
||||
|
||||
Управление доступно в Django admin → «Подключения интеграций»
|
||||
(`/admin/commerce/integrationconnection/`). Здесь можно добавить поддерживаемый
|
||||
сервис, заменить реквизиты, настроить автосинхронизацию и интервал. Пустые поля
|
||||
реквизитов сохраняют прежние значения; сохранённые секреты не показываются.
|
||||
Для проверки и ручного импорта отметьте подключение в списке и выберите
|
||||
«Проверить выбранные подключения» или «Синхронизировать выбранные подключения».
|
||||
Эти действия требуют права изменения подключений. Повторный импорт во время
|
||||
активного запуска не создаёт новую задачу.
|
||||
|
||||
Прогресс и ошибки доступны в «Запусках синхронизации»
|
||||
(`/admin/commerce/integrationsyncrun/`). Проекты и выплаты по проектам также
|
||||
доступны в админке для просмотра; исправления импортированных документов
|
||||
вносятся в сервисе-источнике. Новые провайдеры требуют реализации коннектора;
|
||||
добавление одной записи источника не создаёт интеграцию автоматически.
|
||||
|
||||
Синхронизация любого подключения запускается Celery-задачей
|
||||
`commerce.sync_connection_run`; сервис выбирается по `connection.provider`.
|
||||
Задача публикуется только после commit транзакции, а заранее сформированный
|
||||
|
||||
216
docs/guides/COMPANY_ANALYTICS.md
Normal file
216
docs/guides/COMPANY_ANALYTICS.md
Normal file
@ -0,0 +1,216 @@
|
||||
# Аналитика компании
|
||||
|
||||
Аналитика встроена во вкладку «Аналитика компании» на странице `/finances`.
|
||||
Внутренние вкладки: проекты, продажи, сотрудники, производство и журнал фактов.
|
||||
Выбранные вкладки запоминаются для пользователя; обычный и широкий режимы
|
||||
используют то же переключение, что и управление зарплатой. Фильтры открываются модальным окном по кнопке в шапке финансов;
|
||||
под внутренними вкладками остаётся короткая строка применённых фильтров.
|
||||
Отмена закрывает окно без изменения отчёта. Карточки адаптируются к ширине
|
||||
рабочей области. В обычном режиме и при ширине рабочей области меньше 928px
|
||||
списки по умолчанию свёрнуты до заголовка со счётчиком и стрелкой.
|
||||
После открытия списка табличные строки представлены карточками с раскрывающимися
|
||||
подробностями. В широком режиме списки всегда открыты и показывают таблицы.
|
||||
Это относится к сводным отчётам, документам выплат и журналу фактов.
|
||||
В широком режиме при ширине рабочей области от 928px основной блок сочетает
|
||||
график и таблицу в двух колонках, если таблица помещается без горизонтальной
|
||||
прокрутки. Иначе график располагается сверху, а таблица — снизу на всю ширину.
|
||||
Компоновка пересчитывается при изменении размера области и данных таблицы.
|
||||
Дополнительные блоки располагаются попарно.
|
||||
Таблицы имеют собственную прокрутку и закреплённые заголовки.
|
||||
В обычном и узком режиме графики открываются только модальным окном по кнопке
|
||||
в заголовке списка, в том числе когда список свёрнут.
|
||||
|
||||
У каждого списка есть график с выбором показателя: разные единицы не смешиваются.
|
||||
Месячные данные представлены линией с разрывами на неизвестных значениях;
|
||||
остальные — сравнением до семи позиций. Высота области всех графиков — 280px.
|
||||
Для явно отмеченных суммируемых показателей при числе позиций больше семи
|
||||
показываются шесть крупнейших по модулю и «Прочее» — сумма остальных с учётом
|
||||
знака. Число объединённых позиций видно в подписи и подсказке. Неизвестные
|
||||
значения исключаются и отмечаются отдельно. Проценты, средние, возраст и стаж
|
||||
не суммируются; для них показываются семь крупнейших значений.
|
||||
Месячные ряды сохраняют все месяцы. Полные списки и CSV не сокращаются.
|
||||
Графики сводных списков учитывают поиск по всем страницам результата.
|
||||
Для документов и журнала используется только текущая загруженная страница,
|
||||
что явно подписано над графиком; аннулированные записи журнала исключены.
|
||||
Поиск, страницы, CSV и права на действия сохраняются в обоих режимах. Методика и инструкции открываются по вопросику в шапке,
|
||||
пояснения к отдельным таблицам — по вопросику рядом с названием. Предупреждения
|
||||
о качестве данных остаются в отчётах. Старый адрес `/finances/analytics`
|
||||
перенаправляет на `/finances?tab=analytics`, открывая встроенную вкладку.
|
||||
Используются визуальные компоненты и темы портала. Изображения из локальных
|
||||
материалов служат примерами; их показатели, числа и оформление не являются ТЗ.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Продажи по товарам, каналам, сочетанию товара и канала, клиентам и месяцам.
|
||||
- Выручка, себестоимость, привязанные расходы, прибыль по учтённым расходам и маржа.
|
||||
- Зарплата каждого сотрудника, изменение зарплаты, дни работы, отпуска и больничного,
|
||||
уплаченные налоги, возраст, стаж и динамика численности команды.
|
||||
- Выпуск, продажи произведённых товаров, производственные закупки и графики
|
||||
с переключением между закупками, выручкой и прибылью. Прибыль по товарам и каналам повторно доступна
|
||||
в производственном отчёте и рассчитывается тем же сервисом.
|
||||
- Журнал подтверждённых фактов: выпуск, производственные закупки, уплаченные налоги
|
||||
сотрудника, общие расходы, поступления и выплаты денег.
|
||||
- Выбор дат включительно, валюты и источника продаж. Поиск, страницы таблиц и CSV
|
||||
всех строк текущей группировки с учётом поиска. Диапазон ограничен 367 днями.
|
||||
|
||||
## Источники и методика
|
||||
|
||||
Продажи и возвраты берутся из `commerce.Sale` и `SaleLine`, расходы — из `Cost`.
|
||||
Операции с `counts_as_sale=False` и связанные с ними расходы исключаются.
|
||||
Возвраты уменьшают количество, выручку и себестоимость. Расходы учитываются по
|
||||
собственной дате; их знак сохраняется, в том числе для компенсаций.
|
||||
|
||||
Выручка равна сумме после скидок и возвратов в денежной базе источника. Сервис не
|
||||
пересчитывает НДС. Прибыль по учтённым расходам равна выручке минус себестоимость
|
||||
минус привязанные расходы. Общие расходы и налоги не распределяются автоматически.
|
||||
Этот показатель не следует принимать за окончательную чистую прибыль компании.
|
||||
Правила распределения общехозяйственных расходов и налогов требуют согласования.
|
||||
|
||||
Себестоимость с `metadata.cost_source="unavailable"` считается неизвестной.
|
||||
Нулевая себестоимость без подтверждения `manual` или `known_zero` также считается
|
||||
неизвестной. Для затронутых групп прибыль и маржа возвращаются `null`, в UI — «—».
|
||||
API дополнительно возвращает арифметический результат по известным расходам
|
||||
`contribution_profit`, но интерфейс не показывает его как подтверждённую прибыль.
|
||||
|
||||
Клиенты имеют внешний ID и имя на продаже. Миграция восстанавливает их из
|
||||
сохранённого `SourceRecord.payload.agent`, если информация присутствует. Новая
|
||||
синхронизация МойСклад запрашивает контрагента вместе с документом. ID клиента
|
||||
дополняется ID источника, поэтому одинаковые внешние ID разных систем не
|
||||
объединяются. Когда имеется только имя, используется имя внутри источника;
|
||||
для надёжного сопоставления необходимо заполнить внешний ID.
|
||||
Расходы без привязки к продаже остаются в группе неизвестного клиента.
|
||||
|
||||
Валюты не суммируются и автоматически не конвертируются. Продажи разных систем
|
||||
не дедуплицируются по предполагаемым совпадениям: при дублирующем импорте одного
|
||||
оборота следует выбрать один источник.
|
||||
|
||||
Зарплата берётся из опубликованного расчёта каждого периода. Новая черновая
|
||||
ревизия не заменяет опубликованную. Учитываются только периоды, целиком входящие
|
||||
в выбранные даты. Начисления нескольких периодов одного месяца складываются по
|
||||
месяцу окончания расчётного периода. Средняя зарплата сотрудника — начисленная
|
||||
сумма до удержаний, делённая на число месяцев с расчётами. Среднее компании — по
|
||||
парам «сотрудник / месяц» с расчётами. Неопубликованные и отсутствующие расчёты
|
||||
не заменяются нулями. Рост сравнивает первый и последний месяц с опубликованными расчётами внутри
|
||||
диапазона. Границы сравнения едины для всех сотрудников и показаны в интерфейсе.
|
||||
Сотрудник включается в сравнение только при наличии обоих расчётов и ненулевой
|
||||
начальной зарплате.
|
||||
|
||||
Рабочие дни — записи успешного анализа посещаемости с положительными часами и
|
||||
без признака отсутствия. Отпуска и больничные — календарные дни по кадровым
|
||||
событиям с существующим приоритетом пересекающихся событий. Стаж учитывает
|
||||
увольнения и повторный приём, а не дату создания учётной записи. При отсутствии
|
||||
кадровой истории текущая численность использует активность учётной записи;
|
||||
историческая динамика включает только известную кадровую историю. Неизвестные
|
||||
даты рождения и найма исключаются из средних и учитываются в счётчиках пропусков.
|
||||
|
||||
Налоги берутся только из фактически оплаченных записей журнала. Удержания
|
||||
расчётного листка не означают уплаты налога. Производственный отчёт включает
|
||||
продажи товаров с неаннулированным фактом выпуска не позже конца периода.
|
||||
Количество выпуска вводится в единицах соответствующего товара и не зависит от выбранной валюты. Закупка может
|
||||
относиться к конкретному конечному товару либо оставаться общей. Производственные
|
||||
закупки не вычитаются повторно из прибыли поверх себестоимости продаж.
|
||||
|
||||
Фильтр источника влияет на продажи и связанные расходы; журнал остаётся общим.
|
||||
Денежный поток считается только по поступлениям и выплатам из журнала. Нулевое
|
||||
число записей не подтверждает отсутствие фактических выплат, налогов или выпуска.
|
||||
|
||||
## Доступ и журнал
|
||||
|
||||
Все endpoints требуют авторизации и существующего доступа к управлению финансами,
|
||||
включая allow-list `FinanceSettings.management_users`. Ответы имеют `private,
|
||||
no-store`; фронтовое скрытие ссылки не подменяет серверную проверку.
|
||||
|
||||
Для добавления фактов нужно `finance.add_reportingentry`, для аннулирования —
|
||||
`finance.change_reportingentry`. Действует существующий временный административный
|
||||
доступ `SIMPLE_ADMIN_ACCESS`. Права на просмотр финансов сами по себе не дают права
|
||||
изменять журнал.
|
||||
|
||||
Номер документа уникален внутри вида записи. Записи не редактируются и не удаляются:
|
||||
исправление выполняется аннулированием и новой записью с новым номером. Автор и дата
|
||||
создания, автор и дата аннулирования сохраняются. Повторное аннулирование идемпотентно.
|
||||
Будущие факты, отрицательные суммы и несовместимые с видом записи поля отклоняются.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/finance/analytics/report/?section=sales|people|production`
|
||||
- `GET /api/v1/finance/analytics/options/?search=...`
|
||||
- `GET|POST /api/v1/finance/analytics/entries/`
|
||||
- `POST /api/v1/finance/analytics/entries/{id}/void/`
|
||||
|
||||
Параметры отчёта и списка фактов: `date_from`, `date_to`, `currency`; параметр
|
||||
`source` применяется к продажам. Список фактов использует стандартную пагинацию API.
|
||||
`options` возвращает первые 100 товаров и сотрудников по строке поиска.
|
||||
CSV экранирует кавычки, переносы строк и потенциальные формулы в текстовых полях.
|
||||
|
||||
## Подключение и проверка
|
||||
|
||||
Перед запуском обновлённого backend выполните `python manage.py migrate`:
|
||||
миграции Commerce 0012–0013 и Finance 0032 добавляют клиента продажи и журнал фактов.
|
||||
Для старых документов без имени клиента может потребоваться повторная синхронизация.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/finance/test_analytics.py tests/finance/test_projects.py tests/commerce -q
|
||||
python manage.py makemigrations --check --dry-run
|
||||
python manage.py check
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
node --import tsx --test src/lib/analytics.test.js
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Исходная книга учёта оплат автоматически не импортируется: в ней есть внешние
|
||||
ссылки с ошибками и лист с учётными данными. Импорт этой книги требует отдельного
|
||||
сопоставления колонок, подтверждения исходных сумм и подготовки безопасной копии.
|
||||
Фраза «всё из годового финансового отчёта» требует отдельного перечня показателей
|
||||
или самого годового отчёта: книга оплат не задаёт полный состав такого отчёта.
|
||||
Индекс здоровья, планы и прогнозы из примерных изображений не включены в объём
|
||||
реализации текстового ТЗ.
|
||||
|
||||
## Проекты МойСклад
|
||||
|
||||
Раздел «Проекты» показывает оплаченные расходы по проектам и статьям, документы
|
||||
платежей, ручные выплаты, закупки и общие расходы из журнала, а также продажи проекта.
|
||||
Фильтр проекта работает в продажах, производстве, проектах и журнале. Кадровые
|
||||
показатели не распределяются по проектам. Вариант «Без проекта» позволяет найти
|
||||
документы, для которых привязка не заполнена.
|
||||
|
||||
Синхронизация получает `/entity/project` (включая архив), `/entity/paymentout`
|
||||
и `/entity/cashout` с проектом, статьёй расходов, получателем и валютой. Продажи
|
||||
получают связь `project` из отгрузки или возврата. Для старых продаж связь
|
||||
восстанавливается из сохранённого исходного документа, если поле присутствует.
|
||||
Проекты идентифицируются сочетанием источника и внешнего ID, а не названием.
|
||||
|
||||
«Оплачено в МойСклад» — сумма проведённых исходящих платежей и расходных ордеров
|
||||
за период в выбранной валюте. Проект определяется по явному полю документа;
|
||||
из связанных заказов и приёмок он не угадывается. Непроведённые документы и
|
||||
документы, исчезнувшие после полностью успешного обхода источника, исключаются.
|
||||
Повторный импорт обновляет ту же запись. Последние полученные исходные документы сохраняются; отсутствующие платежи
|
||||
помечаются флагом и не удаляются. Импортированные платежи доступны только для чтения.
|
||||
При ошибке обхода оставшиеся документы не помечаются отсутствующими.
|
||||
|
||||
Платёж может быть переводом между счетами, авансом, погашением долга, закупкой или возвратом: его нельзя
|
||||
автоматически вычитать из прибыли поверх себестоимости. Поэтому оплаченные
|
||||
суммы, ручные выплаты, закупки и общие расходы показаны отдельными колонками.
|
||||
Ручные записи не следует создавать повторно для уже импортированных платежей.
|
||||
При фильтре источника ручные факты относятся к нему через выбранный проект;
|
||||
ручные факты без проекта не включаются в конкретный источник.
|
||||
|
||||
Дополнительный API: `GET /api/v1/finance/analytics/project-payments/` с обычной
|
||||
пагинацией. `section=projects` возвращает сводку, строки проектов, статьи и месяцы.
|
||||
Параметр `project` — локальный ID проекта либо `unassigned`. Поле `project`
|
||||
доступно в ручной записи, продаже и прямом расходе. Справочник проектов возвращается
|
||||
в `analytics/options/`. Права и `no-store` совпадают с остальной аналитикой.
|
||||
|
||||
Миграции: Commerce 0014 и Finance 0033. Обычная синхронизация теперь включает
|
||||
проекты и исходящие платежи. Для отдельной загрузки без товаров и изображений:
|
||||
|
||||
```bash
|
||||
python manage.py sync_moysklad_projects <UUID-подключения>
|
||||
```
|
||||
|
||||
Контракты API: [проекты](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/Project.md),
|
||||
[исходящие платежи](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/PaymentOut.md),
|
||||
[расходные ордера](https://github.com/moysklad/remap-1.2-java-sdk/blob/master/docs/CashOut.md).
|
||||
5
frontend/src/app/finances/analytics/page.tsx
Normal file
5
frontend/src/app/finances/analytics/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function CompanyAnalyticsPage() {
|
||||
redirect("/finances?tab=analytics");
|
||||
}
|
||||
@ -11,9 +11,11 @@ import {
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AnalyticsWorkspace } from "@/components/analytics/AnalyticsWorkspace";
|
||||
import { PayrollDailyWorkSection } from "@/components/finance/PayrollDailyWorkSection";
|
||||
import { PayrollStatementDetailModal } from "@/components/finance/PayrollStatementDetailModal";
|
||||
import { PayrollAdminWorkspace } from "@/components/finance/admin/PayrollAdminWorkspace";
|
||||
@ -76,9 +78,21 @@ function EmptyPayroll() {
|
||||
}
|
||||
|
||||
export default function FinancesPage() {
|
||||
return <Suspense fallback={<AppShell><Loader2 className="app-accent-text animate-spin" /></AppShell>}><FinancesContent /></Suspense>;
|
||||
}
|
||||
|
||||
function FinancesContent() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const showPayrollAdmin = canOpenPayrollAdmin(user);
|
||||
const [activeTab, setActiveTab] = usePayrollFinanceTab(user?.id, showPayrollAdmin);
|
||||
const isWorkspaceTab = activeTab === "management" || activeTab === "analytics";
|
||||
useEffect(() => {
|
||||
if (!user || searchParams.get("tab") !== "analytics") return;
|
||||
if (showPayrollAdmin) setActiveTab("analytics");
|
||||
router.replace("/finances", { scroll: false });
|
||||
}, [router, searchParams, setActiveTab, showPayrollAdmin, user]);
|
||||
const [statements, setStatements] = useState<PayrollStatementSummary[]>([]);
|
||||
const [selectedStatement, setSelectedStatement] = useState<PayrollStatementSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -135,15 +149,17 @@ export default function FinancesPage() {
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
desktopWideMode={activeTab === "management" && effectiveDesktopWideMode}
|
||||
desktopWideMode={isWorkspaceTab && effectiveDesktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
<div className={`min-w-0 space-y-6 ${activeTab === "management" && effectiveDesktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
|
||||
<div className={`min-w-0 space-y-6 ${isWorkspaceTab && effectiveDesktopWideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}>
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="flex min-w-0 flex-nowrap items-start justify-between gap-3">
|
||||
<p className="app-card-caption shrink-0">Финансы</p>
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<div id="finance-management-actions" className="ml-auto min-w-0 max-w-full shrink" />
|
||||
) : activeTab === "analytics" && showPayrollAdmin ? (
|
||||
<div id="finance-analytics-actions" className="ml-auto min-w-0 shrink-0" />
|
||||
) : activeTab === "work" ? (
|
||||
<div id="finance-work-period-selector" className="ml-auto flex min-w-0 flex-1 justify-end sm:w-auto sm:flex-none" />
|
||||
) : null}
|
||||
@ -151,6 +167,8 @@ export default function FinancesPage() {
|
||||
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<div id="finance-management-header" className="mt-4" />
|
||||
) : activeTab === "analytics" && showPayrollAdmin ? (
|
||||
<h1 className="mt-4 text-xl font-semibold">Аналитика компании</h1>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex min-w-0 items-center gap-2">
|
||||
@ -187,6 +205,13 @@ export default function FinancesPage() {
|
||||
Управление
|
||||
</button>
|
||||
) : null}
|
||||
{showPayrollAdmin ? (
|
||||
<button type="button" aria-pressed={activeTab === "analytics"}
|
||||
onClick={() => { setSelectedStatement(null); setActiveTab("analytics"); }}
|
||||
className={`${activeTab === "analytics" ? "app-pill-active" : "app-pill"} inline-flex h-10 items-center rounded-full px-4 text-sm font-medium transition`}>
|
||||
Аналитика компании
|
||||
</button>
|
||||
) : null}
|
||||
</HorizontalChipScroller>
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<div id="finance-management-access" className="ml-auto shrink-0" />
|
||||
@ -203,6 +228,9 @@ export default function FinancesPage() {
|
||||
headerTargetId="finance-management-header"
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
/>
|
||||
) : activeTab === "analytics" && showPayrollAdmin ? (
|
||||
<AnalyticsWorkspace actionsTargetId="finance-analytics-actions"
|
||||
desktopWideMode={effectiveDesktopWideMode} onDesktopWideModeChange={changeDesktopWideMode} />
|
||||
) : activeTab === "work" ? (
|
||||
<PayrollDailyWorkSection periodSelectorTargetId="finance-work-period-selector" />
|
||||
) : (
|
||||
|
||||
@ -874,7 +874,72 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
}
|
||||
}
|
||||
|
||||
.payroll-admin-records-table {
|
||||
.analytics-records-responsive {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analytics-list-responsive {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analytics-list-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.analytics-list-title,
|
||||
.analytics-list-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.analytics-list-wide .analytics-list-toggle,
|
||||
.analytics-list-wide .analytics-records-compact {
|
||||
display: none;
|
||||
}
|
||||
.analytics-list-wide .analytics-list-title {
|
||||
display: flex;
|
||||
}
|
||||
.analytics-list-wide .analytics-list-content,
|
||||
.analytics-list-wide .analytics-records-table {
|
||||
display: block;
|
||||
}
|
||||
.analytics-report-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.analytics-layout-wide .analytics-report-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.analytics-layout-wide .analytics-list-featured {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.analytics-layout-wide .analytics-report-grid > :last-child:nth-child(2) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.analytics-data-layout {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.analytics-list-wide.analytics-list-featured .analytics-data-side-by-side {
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
|
||||
}
|
||||
.analytics-list-wide .analytics-records-table {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.analytics-list-wide .analytics-records-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--surface-primary);
|
||||
}
|
||||
|
||||
.payroll-admin-records-table,
|
||||
.analytics-records-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
6
frontend/src/app/products/layout.tsx
Normal file
6
frontend/src/app/products/layout.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Интерфейс модуля отложен. Импорт и API продолжают обслуживать аналитику.
|
||||
export default function ProductsLayout() {
|
||||
redirect("/");
|
||||
}
|
||||
@ -41,7 +41,6 @@ const modelOrder: SearchModelType[] = [
|
||||
"schedule_event",
|
||||
"procurement_request",
|
||||
"equipment",
|
||||
"product",
|
||||
"task_board",
|
||||
"task",
|
||||
"guest",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, PackageSearch, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -67,7 +67,8 @@ const navItems = [
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
{ href: "/products", label: "Товары", icon: PackageSearch },
|
||||
// Раздел сохранён для дальнейшей разработки; интеграции доступны в Django admin.
|
||||
// { href: "/products", label: "Товары", icon: PackageSearch },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false },
|
||||
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
|
||||
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const },
|
||||
|
||||
20
frontend/src/components/analytics/AnalyticsHelpButton.tsx
Normal file
20
frontend/src/components/analytics/AnalyticsHelpButton.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
export function AnalyticsHelpButton({ title, children }: { title: string; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
return <>
|
||||
<button ref={trigger} type="button" onClick={() => setOpen(true)}
|
||||
className="app-action-ghost inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
aria-label={`Справка: ${title}`} title={`Справка: ${title}`} aria-haspopup="dialog" aria-expanded={open}>
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
<Modal isOpen={open} title={title} size="lg" onClose={() => { setOpen(false); trigger.current?.focus(); }}>
|
||||
<div className="app-text-muted space-y-3 text-sm leading-relaxed">{children}</div>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
93
frontend/src/components/analytics/AnalyticsHelpContent.tsx
Normal file
93
frontend/src/components/analytics/AnalyticsHelpContent.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import type { AnalyticsTab } from "@/hooks/usePayrollTabs";
|
||||
|
||||
export function AnalyticsHelpContent({ tab }: { tab: AnalyticsTab }) {
|
||||
return <>
|
||||
<p>Выберите внутреннюю вкладку, откройте «Фильтры» в шапке финансов, задайте период и нажмите «Показать». Источник и проект применяются там, где эти связи есть в данных. Отчёты строятся в одной выбранной валюте.</p>
|
||||
<p>Кнопка разворота в шапке переключает обычный и широкий вид на компьютере. В широком виде графики находятся рядом с таблицами, а дополнительные наборы данных располагаются в две колонки. На узком экране вкладки прокручиваются по горизонтали, а списки по умолчанию свёрнуты. Нажмите заголовок списка со счётчиком, затем строку со стрелкой, чтобы раскрыть все показатели и доступные действия. Кнопка графика в заголовке списка открывает диаграмму отдельным окном. На узком экране графики показываются только в этих окнах. CSV содержит строки таблицы с учётом поиска.</p>
|
||||
<p>Показатель графика можно переключить: суммы, количество и проценты показываются отдельно. Графики сводных списков учитывают поиск и все страницы результата; графики документов и журнала показывают только текущую страницу, что указано над диаграммой. Графики имеют одинаковую высоту. В сравнении показано до семи позиций: для суммируемых показателей длинный список сокращается до шести крупнейших по модулю и «Прочего» с суммой остальных. Проценты, средние значения, возраст и стаж не складываются: показываются семь крупнейших значений. Знак значений сохраняется. Пропуски данных не заменяются нулями.</p>
|
||||
<p>«Рассчитано» — время построения отчёта. Последнюю загрузку источников и ошибки синхронизации администратор проверяет в админке, в разделе «Подключения интеграций».</p>
|
||||
{tab === "sales" && <>
|
||||
<p>
|
||||
¹ Прибыль = выручка после скидок и возвратов − себестоимость проданных
|
||||
товаров с учётом возвратов − привязанные расходы. Это результат по
|
||||
учтённым расходам, а не окончательная чистая прибыль компании. Общие
|
||||
расходы и налоги не распределяются автоматически.
|
||||
</p>
|
||||
<p>
|
||||
Продажи учитываются по дате операции, расходы — по дате расхода.
|
||||
Отменённые операции исключаются. Расходы без привязки к продаже
|
||||
остаются в группе «Клиент не указан». Прибыль с неизвестной
|
||||
себестоимостью обозначена «—».
|
||||
</p>
|
||||
<p>
|
||||
Закупки не вычитаются повторно из прибыли: себестоимость уже включена
|
||||
в продажи. Денежные поступления и выплаты берутся из журнала фактов.
|
||||
Отсутствие записей обозначено «—». Разные валюты не складываются.
|
||||
</p>
|
||||
<p>
|
||||
Данные разных источников не объединяются по предполагаемому совпадению
|
||||
заказов. Если один и тот же оборот импортирован из двух систем,
|
||||
выберите нужный источник.
|
||||
</p>
|
||||
</>}
|
||||
{tab === "people" && <>
|
||||
<p>
|
||||
Зарплата — начисленная сумма до удержаний из опубликованной ревизии
|
||||
расчёта. Включаются только расчётные периоды, целиком входящие в
|
||||
выбранный диапазон. Несколько расчётов одного месяца суммируются.
|
||||
Среднее по сотруднику делится на число месяцев с расчётом; среднее
|
||||
компании — на число пар «сотрудник / месяц» с расчётом.
|
||||
</p>
|
||||
<p>
|
||||
Изменение зарплаты сравнивает первый и последний месяц с
|
||||
опубликованными расчётами внутри выбранного диапазона; при отсутствии
|
||||
одного из расчётов или нулевой начальной сумме выводится «—». Суммы
|
||||
неполных месяцев и изменение графика могут влиять на сравнение.
|
||||
</p>
|
||||
<p>
|
||||
Отработанные дни считаются по успешному анализу посещаемости с
|
||||
положительными часами работы. Отпуск и больничный — календарные дни по
|
||||
кадровым событиям с учётом пересечений. Отсутствующий табель не
|
||||
считается нулевой выработкой.
|
||||
</p>
|
||||
<p>
|
||||
Налоги — фактически оплаченные суммы из журнала, а не удержания из
|
||||
расчётного листка. Стаж учитывает приём, увольнение и повторный приём.
|
||||
Для текущей численности без кадровой истории используется активность
|
||||
учётной записи. Историческая динамика включает только сотрудников с
|
||||
кадровой историей.
|
||||
</p>
|
||||
</>}
|
||||
{tab === "production" && <>
|
||||
<p>
|
||||
Выпуск и производственные закупки берутся из журнала фактов по дате
|
||||
записи. Закупку можно связать с конечным товаром; общие закупки
|
||||
остаются отдельной строкой. Закупка материалов сама по себе не
|
||||
подтверждает выпуск продукции.
|
||||
</p>
|
||||
<p>
|
||||
Продажи и прибыль используют те же расчёты, что и раздел «Продажи». ¹
|
||||
Общие расходы и налоги не распределяются. Фильтр источника относится к
|
||||
продажам; журнал фактов хранится отдельно. Количество нужно вводить в
|
||||
той же единице товара, в которой учитываются продажи.
|
||||
</p>
|
||||
</>}
|
||||
{tab === "projects" && <>
|
||||
<p>Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж.</p>
|
||||
<p> Проект платежа берётся из поля «Проект» в МойСклад. Платежи без этой
|
||||
связи остаются в группе «Без проекта». Изменяйте импортированные
|
||||
документы в МойСклад. Синхронизацией управляет администратор в админке,
|
||||
в разделе «Подключения интеграций». В журнал добавляйте только
|
||||
отсутствующие в МойСклад факты.
|
||||
Архивные проекты сохраняются в отчётах. Отсутствие загруженных
|
||||
документов не подтверждает отсутствие реальных расходов.
|
||||
</p>
|
||||
</>}
|
||||
{tab === "journal" && <>
|
||||
<p>Журнал хранит выпуск, закупки, налоги и движения денег, которых нет в подключённых источниках. Не дублируйте здесь импортированные платежи МойСклад.</p>
|
||||
<p>Укажите дату, вид операции, сумму или количество и связанные товар, проект или сотрудника. Состав полей зависит от вида операции. Право просмотра не даёт права добавлять и аннулировать записи.</p>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
21
frontend/src/components/analytics/AnalyticsLayout.tsx
Normal file
21
frontend/src/components/analytics/AnalyticsLayout.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
const WideLayoutContext = createContext(false);
|
||||
export const useAnalyticsWideLayout = () => useContext(WideLayoutContext);
|
||||
|
||||
export function AnalyticsLayout({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!container.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||
observer.observe(container.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const wide = enabled && width >= 928;
|
||||
return <WideLayoutContext.Provider value={wide}>
|
||||
<div ref={container} className={`@container min-w-0 space-y-4 ${wide ? "analytics-layout-wide" : ""}`}>{children}</div>
|
||||
</WideLayoutContext.Provider>;
|
||||
}
|
||||
98
frontend/src/components/analytics/AnalyticsListSection.tsx
Normal file
98
frontend/src/components/analytics/AnalyticsListSection.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChartNoAxesCombined, ChevronDown, List } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useAnalyticsWideLayout } from "./AnalyticsLayout";
|
||||
|
||||
export function AnalyticsListSection({ title, count, aside, chart, featured, children }: {
|
||||
title: string;
|
||||
count?: number;
|
||||
aside?: ReactNode;
|
||||
chart?: ReactNode;
|
||||
featured?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [chartOpen, setChartOpen] = useState(false);
|
||||
const chartTrigger = useRef<HTMLButtonElement>(null);
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const recordsRef = useRef<HTMLDivElement>(null);
|
||||
const [sideBySide, setSideBySide] = useState(false);
|
||||
const wide = useAnalyticsWideLayout();
|
||||
const hasChart = Boolean(chart);
|
||||
useEffect(() => {
|
||||
const layout = layoutRef.current;
|
||||
const records = recordsRef.current;
|
||||
if (!wide || !featured || !hasChart || !layout || !records) return;
|
||||
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
const table = records.querySelector("table");
|
||||
if (!table) {
|
||||
setSideBySide(false);
|
||||
return;
|
||||
}
|
||||
// Measure intrinsic width independently of the current arrangement so
|
||||
// switching to a full-width table cannot cause layout oscillation.
|
||||
const previousWidth = table.style.width;
|
||||
table.style.width = "min-content";
|
||||
const requiredWidth = table.getBoundingClientRect().width;
|
||||
table.style.width = previousWidth;
|
||||
const gap = parseFloat(getComputedStyle(layout).columnGap) || 0;
|
||||
const tableColumnWidth = (layout.clientWidth - gap) * 1.15 / 2;
|
||||
const scroller = table.closest<HTMLElement>(".analytics-records-table");
|
||||
const scrollbarWidth = scroller ? scroller.offsetWidth - scroller.clientWidth : 0;
|
||||
setSideBySide(requiredWidth > 0 && requiredWidth <= tableColumnWidth - scrollbarWidth - 1);
|
||||
};
|
||||
const schedule = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(measure);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(schedule);
|
||||
resizeObserver.observe(layout);
|
||||
resizeObserver.observe(records);
|
||||
const mutationObserver = new MutationObserver(schedule);
|
||||
mutationObserver.observe(records, { childList: true, characterData: true, subtree: true });
|
||||
schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [wide, featured, hasChart]);
|
||||
const contentId = useId();
|
||||
const heading = <>
|
||||
<List size={18} className="app-text-muted shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 break-words text-sm font-semibold">{title}</span>
|
||||
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">{count ?? "—"}</span>
|
||||
</>;
|
||||
return <div className={`analytics-list-responsive ${wide ? "analytics-list-wide" : ""} ${featured ? "analytics-list-featured" : ""}`}>
|
||||
<section className="app-surface min-w-0 overflow-hidden rounded-2xl">
|
||||
<div className="flex min-w-0 items-center gap-2 p-4 sm:px-5">
|
||||
<h2 className="min-w-0 flex-1">
|
||||
<button type="button" className="analytics-list-toggle w-full min-w-0 items-center gap-3 text-left"
|
||||
onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls={contentId}
|
||||
aria-label={`${open ? "Свернуть" : "Развернуть"} список «${title}»`}>
|
||||
{heading}<ChevronDown size={18} aria-hidden="true" className={`app-text-muted shrink-0 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<span className="analytics-list-title min-w-0 items-center gap-3">{heading}</span>
|
||||
</h2>
|
||||
{chart && <button ref={chartTrigger} type="button" onClick={() => setChartOpen(true)}
|
||||
aria-label={`График: ${title}`} title={`График: ${title}`} aria-haspopup="dialog" aria-expanded={chartOpen}
|
||||
className="app-action-ghost inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<ChartNoAxesCombined size={18} />
|
||||
</button>}
|
||||
{aside}
|
||||
</div>
|
||||
<div id={contentId} className={`analytics-list-content border-t border-[var(--border-subtle)] p-4 sm:p-5 ${open ? "" : "analytics-list-collapsed"}`}>
|
||||
<div ref={layoutRef} className={chart && wide ? `analytics-data-layout ${sideBySide ? "analytics-data-side-by-side" : ""}` : ""}>
|
||||
{chart && wide && <div className="analytics-inline-chart min-w-0">{chart}</div>}
|
||||
<div ref={recordsRef} className="min-w-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{chart && <Modal isOpen={chartOpen} onClose={() => { setChartOpen(false); chartTrigger.current?.focus(); }}
|
||||
title={title} size="xl">{chart}</Modal>}
|
||||
</div>;
|
||||
}
|
||||
41
frontend/src/components/analytics/AnalyticsRecordCard.tsx
Normal file
41
frontend/src/components/analytics/AnalyticsRecordCard.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function AnalyticsRecordCard({ title, subtitle, summary, status, fields, actions }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
summary: ReactNode;
|
||||
status?: ReactNode;
|
||||
fields: { label: string; value: ReactNode }[];
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailsId = useId();
|
||||
return (
|
||||
<article className="app-surface-muted min-w-0 overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<button type="button" className="flex w-full items-start gap-3 p-3 text-left"
|
||||
onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls={detailsId}>
|
||||
<span className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<ChevronDown size={15} aria-hidden="true" className={`transition ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-words text-sm font-semibold text-[var(--foreground)] [overflow-wrap:anywhere]">{title}</span>
|
||||
{subtitle && <span className="app-text-muted mt-0.5 block break-words text-xs [overflow-wrap:anywhere]">{subtitle}</span>}
|
||||
<span className="mt-2 block break-words text-sm font-medium text-[var(--foreground)] tabular-nums">{summary}</span>
|
||||
</span>
|
||||
{status && <span className="app-status-pill app-text-muted shrink-0 text-xs">{status}</span>}
|
||||
</button>
|
||||
{open && <div id={detailsId} className="border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
|
||||
<dl className="divide-y divide-[var(--border-subtle)]">
|
||||
{fields.map((field) => <div key={field.label} className="grid grid-cols-[minmax(0,40%)_minmax(0,1fr)] items-start gap-3 py-2 text-sm">
|
||||
<dt className="app-text-muted break-words">{field.label}</dt>
|
||||
<dd className="min-w-0 break-words text-right font-medium text-[var(--foreground)] tabular-nums [overflow-wrap:anywhere]">{field.value}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
{actions && <div className="mt-3 flex flex-wrap justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">{actions}</div>}
|
||||
</div>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
336
frontend/src/components/analytics/AnalyticsWorkspace.tsx
Normal file
336
frontend/src/components/analytics/AnalyticsWorkspace.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useAnalyticsTab, type AnalyticsTab } from "@/hooks/usePayrollTabs";
|
||||
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
|
||||
import { AnalyticsHelpButton } from "./AnalyticsHelpButton";
|
||||
import { AnalyticsHelpContent } from "./AnalyticsHelpContent";
|
||||
import { AnalyticsLayout } from "./AnalyticsLayout";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Loader2, Maximize2, Minimize2, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { Modal } from "@/components/ui";
|
||||
import type {
|
||||
AnalyticsOptions,
|
||||
AnalyticsReport,
|
||||
ReportParams,
|
||||
} from "@/types/analytics";
|
||||
import { PeopleReport, ProductionReport, SalesReport } from "./ReportSections";
|
||||
import { ReportingJournal } from "./ReportingJournal";
|
||||
import { ProjectsReport } from "./ProjectsReport";
|
||||
|
||||
function initialPeriod(): ReportParams {
|
||||
const now = new Date();
|
||||
const local = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
return {
|
||||
date_from: `${now.getFullYear()}-01-01`,
|
||||
date_to: local,
|
||||
currency: "RUB",
|
||||
};
|
||||
}
|
||||
const tabs = {
|
||||
projects: "Проекты",
|
||||
sales: "Продажи",
|
||||
people: "Сотрудники",
|
||||
production: "Производство",
|
||||
journal: "Журнал фактов",
|
||||
};
|
||||
|
||||
export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktopWideModeChange }: {
|
||||
actionsTargetId: string;
|
||||
desktopWideMode: boolean;
|
||||
onDesktopWideModeChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { user } = useUser();
|
||||
const [tab, setTab] = useAnalyticsTab(user?.id);
|
||||
const [params, setParams] = useState(initialPeriod);
|
||||
const [draft, setDraft] = useState(params);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filterError, setFilterError] = useState("");
|
||||
const filtersTrigger = useRef<HTMLButtonElement>(null);
|
||||
const [report, setReport] = useState<AnalyticsReport | null>(null);
|
||||
const [options, setOptions] = useState<AnalyticsOptions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [denied, setDenied] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...params,
|
||||
project: tab === "people" ? undefined : params.project,
|
||||
source: tab === "people" || tab === "journal" ? undefined : params.source,
|
||||
section: tab === "journal" ? "sales" : tab,
|
||||
}),
|
||||
[params, tab],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([
|
||||
apiClient.getAnalyticsOptions(),
|
||||
tab === "journal"
|
||||
? Promise.resolve(null)
|
||||
: apiClient.getAnalyticsReport(query),
|
||||
])
|
||||
.then(([o, r]) => {
|
||||
if (active) {
|
||||
setOptions(o);
|
||||
setReport(r);
|
||||
setLoading(false);
|
||||
setDenied(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) {
|
||||
setDenied(e instanceof ApiError && e.status === 403);
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Не удалось загрузить аналитику.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [query, revision, tab]);
|
||||
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setRevision((v) => v + 1);
|
||||
}
|
||||
function openFilters() {
|
||||
setDraft({ ...params });
|
||||
setFilterError("");
|
||||
setFiltersOpen(true);
|
||||
}
|
||||
function closeFilters() {
|
||||
setFiltersOpen(false);
|
||||
filtersTrigger.current?.focus();
|
||||
}
|
||||
function apply(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (draft.date_from > draft.date_to) {
|
||||
setFilterError("Начало периода должно быть не позже окончания.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setParams({ ...draft });
|
||||
closeFilters();
|
||||
}
|
||||
const filterSummary = [
|
||||
`${params.date_from.split("-").reverse().join(".")} — ${params.date_to.split("-").reverse().join(".")}`,
|
||||
params.currency,
|
||||
query.source ? options?.sources.find((source) => String(source.id) === query.source)?.name : null,
|
||||
query.project === "unassigned" ? "Без проекта" : query.project
|
||||
? options?.projects.find((project) => String(project.id) === query.project)?.name : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
const actionsTarget = typeof document !== "undefined" ? document.getElementById(actionsTargetId) : null;
|
||||
const actions = <div className="flex items-center gap-1">
|
||||
<button ref={filtersTrigger} type="button" onClick={openFilters} disabled={denied}
|
||||
className="app-action-ghost inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-2.5 text-xs font-medium"
|
||||
aria-label="Фильтры аналитики" title="Фильтры аналитики" aria-haspopup="dialog" aria-expanded={filtersOpen}>
|
||||
<SlidersHorizontal size={17} /><span className="hidden sm:inline">Фильтры</span>
|
||||
</button>
|
||||
<AnalyticsHelpButton key={tab} title={`Аналитика: ${tabs[tab]}`}><AnalyticsHelpContent tab={tab} /></AnalyticsHelpButton>
|
||||
<button type="button" onClick={reload} className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-full"
|
||||
aria-label="Обновить аналитику" title="Обновить аналитику" disabled={loading}>
|
||||
<RefreshCw size={17} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
<button type="button" onClick={() => onDesktopWideModeChange(!desktopWideMode)}
|
||||
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-full lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть финансы"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть финансы"} aria-pressed={desktopWideMode}>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
</div>;
|
||||
return (
|
||||
<AnalyticsLayout enabled={desktopWideMode}>
|
||||
{actionsTarget ? createPortal(actions, actionsTarget) : <div className="flex justify-end">{actions}</div>}
|
||||
<HorizontalChipScroller ariaLabel="Разделы аналитики">
|
||||
{Object.entries(tabs).map(([key, label]) => (
|
||||
<button type="button" key={key} aria-pressed={tab === key}
|
||||
className={`${tab === key ? "app-pill-active" : "app-pill"} inline-flex shrink-0 items-center rounded-full px-3 py-1.5 text-xs font-medium transition`}
|
||||
onClick={() => {
|
||||
if (tab !== key) {
|
||||
setTab(key as AnalyticsTab);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
}
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</HorizontalChipScroller>
|
||||
<p className="app-text-muted break-words text-xs" aria-label="Активные фильтры аналитики">{filterSummary}</p>
|
||||
<Modal isOpen={filtersOpen} onClose={closeFilters} title="Фильтры аналитики" size="md">
|
||||
<form
|
||||
onSubmit={apply}
|
||||
className="grid grid-cols-2 items-end gap-4"
|
||||
>
|
||||
<label className="app-text-muted text-xs">
|
||||
С даты
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="app-input mt-1 block w-full min-w-0 rounded-xl px-3 py-2.5 text-sm"
|
||||
value={draft.date_from}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, date_from: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="app-text-muted text-xs">
|
||||
По дату включительно
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="app-input mt-1 block w-full min-w-0 rounded-xl px-3 py-2.5 text-sm"
|
||||
value={draft.date_to}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, date_to: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="app-text-muted text-xs">
|
||||
Валюта
|
||||
<input
|
||||
required
|
||||
pattern="[A-Z]{3}"
|
||||
maxLength={3}
|
||||
className="app-input mt-1 block w-full rounded-xl px-3 py-2.5 text-sm"
|
||||
value={draft.currency}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
currency: e.target.value.toUpperCase(),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="app-text-muted text-xs">
|
||||
Источник
|
||||
<select
|
||||
disabled={tab === "people" || tab === "journal"}
|
||||
className="app-select mt-1 block w-full min-w-0 rounded-xl px-3 py-2.5 text-sm"
|
||||
value={draft.source || ""}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
source: e.target.value || undefined,
|
||||
project: undefined,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Все источники</option>
|
||||
{options?.sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="app-text-muted col-span-2 text-xs">
|
||||
Проект
|
||||
<select
|
||||
disabled={tab === "people"}
|
||||
className="app-select mt-1 block w-full min-w-0 rounded-xl px-3 py-2.5 text-sm"
|
||||
value={draft.project || ""}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
project: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Все проекты</option>
|
||||
<option value="unassigned">Без проекта</option>
|
||||
{options?.projects
|
||||
.filter(
|
||||
(p) =>
|
||||
!draft.source || String(p.source_id) === draft.source,
|
||||
)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.archived ? " (архив)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{filterError && <p role="alert" className="app-feedback-danger col-span-2 rounded-lg p-3 text-sm">{filterError}</p>}
|
||||
<div className="col-span-2 mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium" onClick={closeFilters}>Отмена</button>
|
||||
<button type="submit" className="app-action-primary rounded-xl px-4 py-2.5 text-sm font-medium" disabled={loading}>Показать</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
{loading ? (
|
||||
<section className="app-surface flex min-h-64 flex-col items-center justify-center rounded-2xl p-5">
|
||||
<Loader2 size={28} className="app-accent-text animate-spin" />
|
||||
<p className="app-text-muted mt-3 text-sm">Считаем показатели…</p>
|
||||
</section>
|
||||
) : error ? (
|
||||
<section className="app-surface rounded-2xl p-5">
|
||||
<h2 className="font-semibold">
|
||||
{denied
|
||||
? "Доступ к аналитике ограничен"
|
||||
: "Не удалось построить отчёт"}
|
||||
</h2>
|
||||
<p className="app-text-muted mt-2 text-sm" role="alert">
|
||||
{denied
|
||||
? "Раздел доступен сотрудникам с доступом к управлению финансами. Обратитесь к администратору."
|
||||
: error}
|
||||
</p>
|
||||
{!denied && (
|
||||
<button
|
||||
className="app-action-secondary mt-4 rounded-lg px-4 py-2 text-sm"
|
||||
onClick={reload}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{tab === "journal" && options ? (
|
||||
<ReportingJournal
|
||||
key={JSON.stringify(params)}
|
||||
params={params}
|
||||
options={options}
|
||||
onChanged={() => setReport(null)}
|
||||
/>
|
||||
) : (
|
||||
report && (
|
||||
<>
|
||||
<p className="app-text-muted text-xs">
|
||||
Рассчитано{" "}
|
||||
{new Date(report.generated_at).toLocaleString("ru-RU")}
|
||||
</p>
|
||||
{tab === "sales" && report.sales && (
|
||||
<SalesReport report={report} />
|
||||
)}
|
||||
{tab === "projects" && report.projects && (
|
||||
<ProjectsReport
|
||||
key={JSON.stringify(query)}
|
||||
report={report}
|
||||
params={query}
|
||||
/>
|
||||
)}
|
||||
{tab === "people" && report.people && (
|
||||
<PeopleReport report={report} />
|
||||
)}
|
||||
{tab === "production" && report.production && (
|
||||
<ProductionReport report={report} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnalyticsLayout>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/analytics/DatasetChart.tsx
Normal file
72
frontend/src/components/analytics/DatasetChart.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import type { AnalyticsRow } from "@/types/analytics";
|
||||
import { formatMetric, prepareAnalyticsChart } from "@/lib/analytics";
|
||||
|
||||
export type ChartMetric = { key: string; label: string; digits?: number; additive?: boolean };
|
||||
|
||||
export function DatasetChart({ title, rows, metrics, labelKeys, initialMetric, scope }: {
|
||||
title: string;
|
||||
rows: AnalyticsRow[];
|
||||
metrics: ChartMetric[];
|
||||
labelKeys: string[];
|
||||
initialMetric?: string;
|
||||
scope?: string;
|
||||
}) {
|
||||
const selectId = useId();
|
||||
const [selected, setSelected] = useState(initialMetric || metrics[0]?.key);
|
||||
const metric = metrics.find((item) => item.key === selected) || metrics[0];
|
||||
const data = useMemo(() => prepareAnalyticsChart(rows, metric?.key || "", labelKeys, metric?.additive), [rows, metric?.key, metric?.additive, labelKeys]);
|
||||
if (!metric) return null;
|
||||
const compact = (value: number) => new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
||||
const tooltip = <Tooltip position={{ x: 0, y: 0 }}
|
||||
contentStyle={{ background: "var(--surface-elevated)", border: "1px solid var(--border-subtle)", borderRadius: 12,
|
||||
color: "var(--foreground)", maxWidth: 260, whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
labelFormatter={(_, payload) => payload?.[0]?.payload?.id === "analytics-other"
|
||||
? `Прочее: ${data.groupedCount} позиций (сумма с учётом знака)`
|
||||
: String(payload?.[0]?.payload?.label || "")}
|
||||
formatter={(value) => [formatMetric(value as number, metric.digits ?? 2), metric.label]}
|
||||
/>;
|
||||
const tick = { fill: "var(--muted-foreground)", fontSize: 11 };
|
||||
const hasValues = data.total > data.missing;
|
||||
return <div className="min-w-0 space-y-3">
|
||||
<div>
|
||||
<label htmlFor={selectId} className="app-text-muted block text-xs">Показатель на графике</label>
|
||||
<select id={selectId} aria-label={`Показатель графика: ${title}`} value={metric.key}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
className="app-select mt-1 w-full min-w-0 rounded-xl px-3 py-2 text-sm">
|
||||
{metrics.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{scope && <p className="app-text-muted text-xs">{scope}</p>}
|
||||
{!hasValues ? <div className="app-surface-muted flex h-[280px] items-center justify-center rounded-xl p-5 text-center text-sm">
|
||||
{rows.length ? "Недостаточно данных для выбранного показателя. Неизвестные значения не заменяются нулями." : "Нет данных для графика при текущих фильтрах."}
|
||||
</div> : <>
|
||||
<p className="app-text-muted text-xs">{data.monthly ? "Динамика по месяцам" : data.groupedCount
|
||||
? `6 крупнейших по модулю · Прочее: ещё ${data.groupedCount}`
|
||||
: `Крупнейшие по абсолютному значению: ${data.points.length} из ${data.total - data.missing}`}</p>
|
||||
<div style={{ height: 280 }}
|
||||
className="min-w-0" role="img" aria-label={`${title}: ${metric.label}. Точные значения доступны в связанном списке.`}>
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
||||
{data.monthly ? <LineChart data={data.points} margin={{ top: 12, right: 12, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--border-subtle)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={tick} tickFormatter={(value) => String(value).slice(5)} />
|
||||
<YAxis tick={tick} tickFormatter={compact} width={56} />
|
||||
{tooltip}
|
||||
<Line name={metric.label} dataKey="value" stroke="var(--accent-primary)" strokeWidth={2.5} dot={{ r: 3 }} connectNulls={false} isAnimationActive={false} />
|
||||
</LineChart> : <BarChart data={data.points} layout="vertical" margin={{ top: 0, right: 12, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--border-subtle)" horizontal={false} />
|
||||
<XAxis type="number" tick={tick} tickFormatter={compact} />
|
||||
<YAxis type="category" dataKey="label" width={104} tick={tick} interval={0}
|
||||
tickFormatter={(value) => String(value).length > 14 ? `${String(value).slice(0, 14)}…` : String(value)} />
|
||||
{tooltip}
|
||||
<Bar name={metric.label} dataKey="value" fill="var(--accent-primary)" maxBarSize={20} radius={3} isAnimationActive={false} />
|
||||
</BarChart>}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>}
|
||||
{data.missing > 0 && <p className="app-text-muted text-xs">Без значения: {data.missing}. Прочерки в списке означают отсутствие данных.</p>}
|
||||
</div>;
|
||||
}
|
||||
252
frontend/src/components/analytics/ProjectsReport.tsx
Normal file
252
frontend/src/components/analytics/ProjectsReport.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { formatMetric } from "@/lib/analytics";
|
||||
import type {
|
||||
AnalyticsReport,
|
||||
ReportParams,
|
||||
ProjectPayment,
|
||||
} from "@/types/analytics";
|
||||
import { ReportTable } from "./ReportTable";
|
||||
import { AnalyticsRecordCard } from "./AnalyticsRecordCard";
|
||||
import { DatasetChart } from "./DatasetChart";
|
||||
import { AnalyticsListSection } from "./AnalyticsListSection";
|
||||
|
||||
export function ProjectsReport({
|
||||
report,
|
||||
params,
|
||||
}: {
|
||||
report: AnalyticsReport;
|
||||
params: ReportParams;
|
||||
}) {
|
||||
const data = report.projects!;
|
||||
const unit = report.currency;
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-4">
|
||||
{[
|
||||
["Оплачено по МойСклад", data.summary.paid],
|
||||
["Из них без проекта", data.summary.unassigned_paid],
|
||||
["Ручные выплаты", data.summary.manual_paid],
|
||||
["Общие расходы по журналу", data.summary.overhead],
|
||||
].map(([label, value]) => (
|
||||
<section key={String(label)} className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<p className="app-text-muted text-sm">{label}</p>
|
||||
<p className="mt-3 break-words text-2xl font-semibold tabular-nums">
|
||||
{formatMetric(value, 2)}{" "}
|
||||
<span className="app-text-muted text-sm">{unit}</span>
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="analytics-report-grid">
|
||||
<ReportTable
|
||||
featured
|
||||
title="Расходы и продажи по проектам"
|
||||
rows={data.rows}
|
||||
filename={`projects-${report.period.from}-${report.period.to}-${unit}`}
|
||||
note="Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж."
|
||||
columns={[
|
||||
{ key: "name", label: "Проект", text: true },
|
||||
{ key: "paid", additive: true, label: `Оплачено в МойСклад, ${unit}`, digits: 2 },
|
||||
{ key: "payment_count", additive: true, label: "Платежей", digits: 0 },
|
||||
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
|
||||
{
|
||||
key: "overhead", additive: true,
|
||||
label: `Общие расходы по журналу, ${unit}`,
|
||||
digits: 2,
|
||||
},
|
||||
{ key: "purchases", additive: true, label: `Закупки по журналу, ${unit}`, digits: 2 },
|
||||
{ key: "revenue", additive: true, label: `Выручка, ${unit}`, digits: 2 },
|
||||
{
|
||||
key: "expenses", additive: true,
|
||||
label: `Прямые расходы продаж, ${unit}`,
|
||||
digits: 2,
|
||||
},
|
||||
{
|
||||
key: "profit", additive: true,
|
||||
label: `Прибыль продаж по учтённым расходам, ${unit}`,
|
||||
digits: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Выплаты по месяцам"
|
||||
rows={data.monthly}
|
||||
filename={`project-months-${report.period.from}-${report.period.to}-${unit}`}
|
||||
columns={[
|
||||
{ key: "name", label: "Месяц", text: true },
|
||||
{ key: "paid", additive: true, label: `МойСклад, ${unit}`, digits: 2 },
|
||||
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
|
||||
]}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Статьи оплаченных расходов МойСклад"
|
||||
rows={data.categories}
|
||||
filename={`project-expenses-${report.period.from}-${report.period.to}-${unit}`}
|
||||
columns={[
|
||||
{ key: "name", label: "Статья", text: true },
|
||||
{ key: "paid", additive: true, label: `Оплачено, ${unit}`, digits: 2 },
|
||||
{ key: "payment_count", additive: true, label: "Платежей", digits: 0 },
|
||||
]}
|
||||
/>
|
||||
<ProjectPayments params={params} />
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectPayments({ params }: { params: ReportParams }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [data, setData] = useState<{
|
||||
count: number;
|
||||
next: string | null;
|
||||
results: ProjectPayment[];
|
||||
} | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
apiClient
|
||||
.getProjectPayments({ ...params, page })
|
||||
.then((v) => {
|
||||
if (active) setData(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active)
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Не удалось загрузить платежи.",
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [params, page, revision]);
|
||||
return (
|
||||
<AnalyticsListSection title="Документы оплаченных расходов" count={data?.count} featured
|
||||
chart={<DatasetChart title="Документы оплаченных расходов"
|
||||
rows={(data?.results || []).map((payment) => ({ id: payment.id, name: `${payment.number || "Без номера"} · ${new Date(payment.occurred_at).toLocaleDateString("ru-RU")}`, amount: payment.amount }))}
|
||||
metrics={[{ key: "amount", additive: true, label: `Сумма, ${params.currency}` }]} labelKeys={["name"]}
|
||||
scope={`Только текущая страница: ${data?.results.length ?? 0} из ${data?.count ?? 0} документов. График не является итогом по всем платежам.`} />}>
|
||||
{error ? (
|
||||
<p role="alert" className="app-feedback-danger mt-4 rounded-xl p-3">
|
||||
{error}
|
||||
<button
|
||||
className="ml-3 underline"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setRevision((v) => v + 1);
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</p>
|
||||
) : !data ? (
|
||||
<p className="app-text-muted py-8">Загружаем документы…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="analytics-records-responsive mt-4">
|
||||
<div className="analytics-records-compact space-y-2">
|
||||
{data.results.map((payment) => <AnalyticsRecordCard key={payment.id}
|
||||
title={`${payment.document_type === "cashout" ? "Расходный ордер" : "Платёж"} ${payment.number}`}
|
||||
subtitle={`${new Date(payment.occurred_at).toLocaleDateString("ru-RU")} · ${payment.project_name || "Без проекта"}`}
|
||||
summary={`${formatMetric(payment.amount, 2)} ${payment.currency}`}
|
||||
fields={[
|
||||
{ label: "Дата", value: new Date(payment.occurred_at).toLocaleDateString("ru-RU") },
|
||||
{ label: "Документ", value: payment.number || "—" },
|
||||
{ label: "Проект", value: payment.project_name || "Без проекта" },
|
||||
{ label: "Статья", value: payment.expense_category || "Статья не указана" },
|
||||
{ label: "Получатель", value: payment.payee || "—" },
|
||||
{ label: "Назначение", value: payment.description || "—" },
|
||||
{ label: "Сумма", value: `${formatMetric(payment.amount, 2)} ${payment.currency}` },
|
||||
]}
|
||||
/>)}
|
||||
</div>
|
||||
<div className="analytics-records-table overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="app-text-muted">
|
||||
<tr>
|
||||
{[
|
||||
"Дата / документ",
|
||||
"Проект",
|
||||
"Статья / получатель",
|
||||
"Назначение",
|
||||
"Сумма",
|
||||
].map((h) => (
|
||||
<th className="px-3 py-3 font-medium" key={h}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.results.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-t border-[var(--border-subtle)]"
|
||||
>
|
||||
<td className="min-w-40 px-3 py-3">
|
||||
{new Date(p.occurred_at).toLocaleDateString("ru-RU")}
|
||||
<p className="app-text-muted mt-1">
|
||||
{p.document_type === "cashout"
|
||||
? "Расходный ордер"
|
||||
: "Платёж"}{" "}
|
||||
{p.number}
|
||||
</p>
|
||||
</td>
|
||||
<td className="min-w-36 px-3 py-3">
|
||||
{p.project_name || "Без проекта"}
|
||||
</td>
|
||||
<td className="min-w-44 px-3 py-3">
|
||||
{p.expense_category || "Статья не указана"}
|
||||
<p className="app-text-muted mt-1">{p.payee}</p>
|
||||
</td>
|
||||
<td className="min-w-48 px-3 py-3">
|
||||
{p.description || "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 tabular-nums">
|
||||
{formatMetric(p.amount, 2)} {p.currency}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{!data.count && (
|
||||
<p className="app-text-muted py-8 text-center">
|
||||
За выбранный период платежи не загружены.
|
||||
</p>
|
||||
)}
|
||||
{(page > 1 || data.next) && (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-3 text-sm">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
className="app-action-secondary rounded-lg px-3 py-2"
|
||||
onClick={() => {
|
||||
setData(null);
|
||||
setPage((v) => v - 1);
|
||||
}}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span>Страница {page}</span>
|
||||
<button
|
||||
disabled={!data.next}
|
||||
className="app-action-secondary rounded-lg px-3 py-2"
|
||||
onClick={() => {
|
||||
setData(null);
|
||||
setPage((v) => v + 1);
|
||||
}}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnalyticsListSection>
|
||||
);
|
||||
}
|
||||
347
frontend/src/components/analytics/ReportSections.tsx
Normal file
347
frontend/src/components/analytics/ReportSections.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { AnalyticsReport, MetricValue } from "@/types/analytics";
|
||||
import { formatMetric } from "@/lib/analytics";
|
||||
import { ReportTable, type ReportColumn } from "./ReportTable";
|
||||
|
||||
function Metrics({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
label: string;
|
||||
value: MetricValue | undefined | boolean;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<section
|
||||
key={item.label}
|
||||
className="app-surface min-w-0 rounded-2xl p-4 sm:p-5"
|
||||
>
|
||||
<p className="app-text-muted text-sm">{item.label}</p>
|
||||
<p className="mt-3 break-words text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{formatMetric(item.value, 1)}
|
||||
<span className="app-text-muted ml-1 text-sm font-normal">
|
||||
{item.unit}
|
||||
</span>
|
||||
</p>
|
||||
{item.hint && (
|
||||
<p className="app-text-muted mt-2 text-xs">{item.hint}</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const moneyColumns = (currency: string): ReportColumn[] => [
|
||||
{ key: "quantity", additive: true, label: "Продано − возвраты, ед." },
|
||||
{ key: "revenue", additive: true, label: `Выручка, ${currency}` },
|
||||
{ key: "cost_of_goods", additive: true, label: `Себестоимость, ${currency}` },
|
||||
{ key: "expenses", additive: true, label: `Расходы, ${currency}` },
|
||||
{ key: "profit", additive: true, label: `Прибыль¹, ${currency}` },
|
||||
{ key: "margin_percent", label: "Маржа¹, %" },
|
||||
{ key: "missing_cost_lines", additive: true, label: "Без себестоимости", digits: 0 },
|
||||
];
|
||||
|
||||
export function SalesReport({ report }: { report: AnalyticsReport }) {
|
||||
const [group, setGroup] = useState<
|
||||
"products" | "channels" | "customers" | "product_channels"
|
||||
>("product_channels");
|
||||
const data = report.sales!;
|
||||
const entries = report.production!;
|
||||
const unit = report.currency;
|
||||
const filename = `sales-${group}-${report.period.from}-${report.period.to}-${unit}`;
|
||||
const groups = {
|
||||
product_channels: "Товары и каналы",
|
||||
products: "Товары",
|
||||
channels: "Каналы продаж",
|
||||
customers: "Клиенты",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Metrics
|
||||
items={[
|
||||
{ label: "Выручка", value: data.summary.revenue, unit },
|
||||
{
|
||||
label: "Прибыль по учтённым расходам¹",
|
||||
value: data.summary.profit,
|
||||
unit,
|
||||
hint: "Без распределения общих расходов и налогов",
|
||||
},
|
||||
{
|
||||
label: "Общие расходы отдельно",
|
||||
value: entries.record_counts.overhead
|
||||
? entries.totals.overhead
|
||||
: null,
|
||||
unit,
|
||||
hint: `${entries.record_counts.overhead} записей в журнале`,
|
||||
},
|
||||
{
|
||||
label: "Денежный поток по журналу",
|
||||
value:
|
||||
entries.record_counts.cash_in || entries.record_counts.cash_out
|
||||
? Number(entries.totals.cash_in) -
|
||||
Number(entries.totals.cash_out)
|
||||
: null,
|
||||
unit,
|
||||
hint: "Поступления минус выплаты",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{(data.quality.missing_cost_lines > 0 ||
|
||||
data.quality.missing_customer_lines > 0 ||
|
||||
data.quality.unmatched_product_lines > 0) && (
|
||||
<div
|
||||
className="app-feedback-warning rounded-xl p-4 text-sm"
|
||||
role="status"
|
||||
>
|
||||
Проверьте полноту данных: без себестоимости —{" "}
|
||||
{data.quality.missing_cost_lines}, без клиента —{" "}
|
||||
{data.quality.missing_customer_lines}, без сопоставленного товара —{" "}
|
||||
{data.quality.unmatched_product_lines} позиций. Прибыль не
|
||||
рассчитывается для строк с неизвестной себестоимостью.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Группировка продаж"
|
||||
>
|
||||
{Object.entries(groups).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`${group === key ? "app-pill-active" : "app-pill"} rounded-full px-4 py-2 text-sm`}
|
||||
aria-pressed={group === key}
|
||||
onClick={() => setGroup(key as typeof group)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="analytics-report-grid">
|
||||
<ReportTable
|
||||
featured
|
||||
key={group}
|
||||
title={groups[group]}
|
||||
rows={data[group]}
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
label:
|
||||
group === "customers"
|
||||
? "Клиент"
|
||||
: group === "channels"
|
||||
? "Канал"
|
||||
: "Товар",
|
||||
text: true,
|
||||
},
|
||||
...(group === "product_channels"
|
||||
? [{ key: "channel", label: "Канал", text: true }]
|
||||
: []),
|
||||
...moneyColumns(unit),
|
||||
]}
|
||||
filename={filename}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Продажи по месяцам"
|
||||
rows={data.monthly}
|
||||
columns={[
|
||||
{ key: "id", label: "Месяц", text: true },
|
||||
...moneyColumns(unit),
|
||||
]}
|
||||
filename={`sales-monthly-${report.period.from}-${unit}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeopleReport({ report }: { report: AnalyticsReport }) {
|
||||
const data = report.people!;
|
||||
const summary = data.summary;
|
||||
const name = (id: MetricValue | undefined | boolean) =>
|
||||
data.employees.find((e) => e.id === id)?.name || "Недостаточно данных";
|
||||
const columns: ReportColumn[] = [
|
||||
{ key: "name", label: "Сотрудник", text: true },
|
||||
{ key: "average_salary", label: `Средняя за месяц, ${report.currency}` },
|
||||
{ key: "salary_months", label: "Месяцев расчёта", digits: 0 },
|
||||
{ key: "salary_growth_percent", label: "Изменение зарплаты, %" },
|
||||
{ key: "worked_days", additive: true, label: "Отработано дней", digits: 0 },
|
||||
{ key: "attendance_days", additive: true, label: "Дней в табеле", digits: 0 },
|
||||
{ key: "vacation_days", additive: true, label: "Отпуск, календ. дней", digits: 0 },
|
||||
{ key: "sick_days", additive: true, label: "Больничный, календ. дней", digits: 0 },
|
||||
{ key: "tax_paid", additive: true, label: `Уплачено налогов, ${report.currency}` },
|
||||
{ key: "age", label: "Возраст", digits: 0 },
|
||||
{ key: "tenure_days", label: "Стаж, дней", digits: 0 },
|
||||
];
|
||||
const rows = data.employees.map((e) => ({
|
||||
...e,
|
||||
worked_days: e.attendance_days ? e.worked_days : null,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<Metrics
|
||||
items={[
|
||||
{
|
||||
label: "Средняя начисленная зарплата",
|
||||
value: summary.average_salary,
|
||||
unit: report.currency,
|
||||
hint: "По опубликованным расчётам",
|
||||
},
|
||||
{
|
||||
label: "Сотрудников на конец периода",
|
||||
value: summary.headcount,
|
||||
hint: `Мужчин: ${summary.men} · женщин: ${summary.women} · не указан: ${summary.unspecified_gender}`,
|
||||
},
|
||||
{ label: "Средний возраст", value: summary.average_age, unit: "лет" },
|
||||
{
|
||||
label: "Средний стаж",
|
||||
value:
|
||||
summary.average_tenure_days == null
|
||||
? null
|
||||
: Number(summary.average_tenure_days) / 365.25,
|
||||
unit: "лет",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<section className="app-surface grid gap-5 rounded-2xl p-4 @min-[600px]:grid-cols-3 sm:p-5">
|
||||
<div>
|
||||
<p className="app-text-muted text-sm">
|
||||
Наибольшее изменение зарплаты ({String(summary.growth_from ?? "—")}{" "}
|
||||
— {String(summary.growth_to ?? "—")})
|
||||
</p>
|
||||
<p className="mt-2 font-medium">{name(summary.fastest_growth)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="app-text-muted text-sm">
|
||||
Наименьшее изменение зарплаты
|
||||
</p>
|
||||
<p className="mt-2 font-medium">{name(summary.slowest_growth)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="app-text-muted text-sm">Уплаченные налоги по журналу</p>
|
||||
<p className="mt-2 font-medium">
|
||||
{formatMetric(summary.tax_paid, 2)} {report.currency}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="analytics-report-grid">
|
||||
<ReportTable
|
||||
featured
|
||||
title="Показатели сотрудников"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filename={`employees-${report.period.from}-${report.period.to}-${report.currency}`}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Динамика команды"
|
||||
rows={data.monthly}
|
||||
columns={[
|
||||
{ key: "id", label: "Месяц", text: true },
|
||||
{
|
||||
key: "average_salary",
|
||||
label: `Средняя зарплата, ${report.currency}`,
|
||||
},
|
||||
{ key: "salary_total", additive: true, label: `Начислено, ${report.currency}` },
|
||||
{
|
||||
key: "headcount_with_history",
|
||||
label: "Сотрудников с кадровой историей",
|
||||
digits: 0,
|
||||
},
|
||||
{ key: "average_age", label: "Средний возраст, лет" },
|
||||
{ key: "average_tenure_days", label: "Средний стаж, дней" },
|
||||
{ key: "men", label: "Мужчин", digits: 0 },
|
||||
{ key: "women", label: "Женщин", digits: 0 },
|
||||
]}
|
||||
filename={`team-monthly-${report.period.from}-${report.currency}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="app-feedback-warning rounded-xl p-3 text-sm">
|
||||
<p>
|
||||
На конец периода нет даты найма у {summary.missing_hire_dates}{" "}
|
||||
сотрудников, даты рождения — у {summary.missing_birth_dates}. Они не
|
||||
включаются в соответствующие средние.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductionReport({ report }: { report: AnalyticsReport }) {
|
||||
const data = report.production!;
|
||||
return (
|
||||
<>
|
||||
<Metrics
|
||||
items={[
|
||||
{
|
||||
label: "Выпущено по журналу",
|
||||
value: data.record_counts.production
|
||||
? data.totals.production
|
||||
: null,
|
||||
unit: "ед.",
|
||||
},
|
||||
{
|
||||
label: "Продано с учётом возвратов",
|
||||
value: report.sales!.summary.quantity,
|
||||
unit: "ед.",
|
||||
hint: "По выбранному источнику продаж",
|
||||
},
|
||||
{
|
||||
label: "Закупки для производства",
|
||||
value: data.record_counts.purchase ? data.totals.purchase : null,
|
||||
unit: report.currency,
|
||||
},
|
||||
{
|
||||
label: "Прибыль по учтённым расходам¹",
|
||||
value: report.sales!.summary.profit,
|
||||
unit: report.currency,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="analytics-report-grid">
|
||||
<ReportTable
|
||||
featured
|
||||
title="Выпуск и продажи по товарам"
|
||||
note="Продажи включают товары с зарегистрированным выпуском до конца выбранного периода. Чтобы добавить товар в производственную аналитику, внесите факт выпуска в журнал."
|
||||
rows={data.products}
|
||||
columns={[
|
||||
{ key: "name", label: "Товар", text: true },
|
||||
{ key: "produced", additive: true, label: "Произведено, ед." },
|
||||
{ key: "purchases", additive: true, label: `Закупки, ${report.currency}` },
|
||||
...moneyColumns(report.currency),
|
||||
]}
|
||||
filename={`production-${report.period.from}-${report.period.to}-${report.currency}`}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Прибыль товаров по каналам продаж"
|
||||
rows={data.product_channels}
|
||||
columns={[
|
||||
{ key: "name", label: "Товар", text: true },
|
||||
{ key: "channel", label: "Канал", text: true },
|
||||
...moneyColumns(report.currency),
|
||||
]}
|
||||
filename={`production-channels-${report.period.from}-${report.currency}`}
|
||||
/>
|
||||
<ReportTable
|
||||
title="Производство по месяцам"
|
||||
rows={data.monthly}
|
||||
columns={[
|
||||
{ key: "id", label: "Месяц", text: true },
|
||||
{ key: "produced", additive: true, label: "Выпуск, ед." },
|
||||
{ key: "purchases", additive: true, label: `Закупки, ${report.currency}` },
|
||||
{ key: "revenue", additive: true, label: `Выручка, ${report.currency}` },
|
||||
{ key: "profit", additive: true, label: `Прибыль¹, ${report.currency}` },
|
||||
]}
|
||||
filename={`production-monthly-${report.period.from}-${report.currency}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/analytics/ReportTable.tsx
Normal file
171
frontend/src/components/analytics/ReportTable.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AnalyticsHelpButton } from "./AnalyticsHelpButton";
|
||||
import { AnalyticsRecordCard } from "./AnalyticsRecordCard";
|
||||
import { AnalyticsListSection } from "./AnalyticsListSection";
|
||||
import { DatasetChart } from "./DatasetChart";
|
||||
import { Download, Search } from "lucide-react";
|
||||
import { buildAnalyticsCsv, formatMetric } from "@/lib/analytics";
|
||||
import type { AnalyticsRow } from "@/types/analytics";
|
||||
|
||||
export type ReportColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
text?: boolean;
|
||||
digits?: number;
|
||||
additive?: boolean;
|
||||
};
|
||||
export function ReportTable({
|
||||
title,
|
||||
rows,
|
||||
columns,
|
||||
filename,
|
||||
note,
|
||||
featured,
|
||||
}: {
|
||||
title: string;
|
||||
rows: AnalyticsRow[];
|
||||
columns: ReportColumn[];
|
||||
filename: string;
|
||||
note?: string;
|
||||
featured?: boolean;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
rows.filter((row) =>
|
||||
`${row.name ?? ""} ${row.channel ?? ""} ${row.id}`
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.includes(search.toLocaleLowerCase("ru-RU")),
|
||||
),
|
||||
[rows, search],
|
||||
);
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / 20));
|
||||
const current = Math.min(page, pages - 1);
|
||||
const visibleRows = filtered.slice(current * 20, current * 20 + 20);
|
||||
const titleColumn = columns.find((column) => column.text);
|
||||
const subtitleColumn = columns.find((column) => column.text && column !== titleColumn);
|
||||
const summaryColumn = ["paid", "revenue", "average_salary", "produced", "production"]
|
||||
.map((key) => columns.find((column) => column.key === key))
|
||||
.find(Boolean) || columns.find((column) => !column.text);
|
||||
const cellValue = (row: AnalyticsRow, column: ReportColumn) => column.text
|
||||
? String(row[column.key] ?? "—") : formatMetric(row[column.key], column.digits ?? 2);
|
||||
function download() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([buildAnalyticsCsv(filtered, columns)], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
}),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename + ".csv";
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
return (
|
||||
<AnalyticsListSection title={title} count={filtered.length} featured={featured}
|
||||
chart={<DatasetChart title={title} rows={filtered} metrics={columns.filter((column) => !column.text)}
|
||||
initialMetric={summaryColumn?.key} labelKeys={columns.filter((column) => column.text).map((column) => column.key)}
|
||||
scope={search ? `Поиск: «${search}» · найдено ${filtered.length} строк` : `Все ${filtered.length} строк списка, включая другие страницы`} />}
|
||||
aside={note ? <AnalyticsHelpButton title={title}><p>{note}</p></AnalyticsHelpButton> : undefined}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<button
|
||||
className="app-action-secondary flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
onClick={download}
|
||||
disabled={!filtered.length}
|
||||
>
|
||||
<Download size={16} /> Скачать CSV
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative mt-4 block max-w-sm">
|
||||
<Search className="app-text-muted absolute left-3 top-3" size={16} />
|
||||
<input
|
||||
aria-label={`Поиск: ${title}`}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Поиск по названию"
|
||||
className="app-input w-full rounded-xl py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="analytics-records-responsive mt-4">
|
||||
<div className="analytics-records-compact space-y-2">
|
||||
{visibleRows.map((row) => <AnalyticsRecordCard key={row.id}
|
||||
title={titleColumn ? cellValue(row, titleColumn) : String(row.name ?? row.id)}
|
||||
subtitle={subtitleColumn ? cellValue(row, subtitleColumn) : undefined}
|
||||
summary={summaryColumn ? <><span className="app-text-muted text-xs">{summaryColumn.label}: </span>{cellValue(row, summaryColumn)}</> : null}
|
||||
fields={columns.map((column) => ({ label: column.label, value: cellValue(row, column) }))}
|
||||
/>)}
|
||||
</div>
|
||||
<div className="analytics-records-table overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="app-text-muted border-b border-[var(--border-subtle)]">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={`px-3 py-3 font-medium ${c.text ? "min-w-44" : "min-w-28 text-right"}`}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-[var(--border-subtle)] last:border-0"
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`px-3 py-3 ${c.text ? "max-w-xs break-words" : "text-right tabular-nums"}`}
|
||||
>
|
||||
{c.text
|
||||
? String(row[c.key] ?? "—")
|
||||
: formatMetric(row[c.key], c.digits ?? 2)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!filtered.length && (
|
||||
<p className="app-text-muted py-10 text-center text-sm">
|
||||
Нет данных для выбранного периода и фильтров.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{filtered.length > 20 && (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<span className="app-text-muted">
|
||||
{current + 1} из {pages} · строк: {filtered.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="app-action-secondary rounded-lg px-3 py-2"
|
||||
disabled={current === 0}
|
||||
onClick={() => setPage(current - 1)}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className="app-action-secondary rounded-lg px-3 py-2"
|
||||
disabled={current + 1 >= pages}
|
||||
onClick={() => setPage(current + 1)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnalyticsListSection>
|
||||
);
|
||||
}
|
||||
509
frontend/src/components/analytics/ReportingJournal.tsx
Normal file
509
frontend/src/components/analytics/ReportingJournal.tsx
Normal file
@ -0,0 +1,509 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { AnalyticsRecordCard } from "./AnalyticsRecordCard";
|
||||
import { DatasetChart } from "./DatasetChart";
|
||||
import { AnalyticsListSection } from "./AnalyticsListSection";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { formatMetric, reportingKinds } from "@/lib/analytics";
|
||||
import type {
|
||||
AnalyticsOptions,
|
||||
ReportParams,
|
||||
ReportingEntry,
|
||||
ReportingEntryInput,
|
||||
ReportingKind,
|
||||
} from "@/types/analytics";
|
||||
|
||||
function EntryForm({
|
||||
currency,
|
||||
projects,
|
||||
project,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
currency: string;
|
||||
projects: AnalyticsOptions["projects"];
|
||||
project?: string;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<ReportingKind>("production");
|
||||
const [options, setOptions] = useState<AnalyticsOptions | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const timeout = setTimeout(() => {
|
||||
apiClient
|
||||
.getAnalyticsOptions(search)
|
||||
.then((v) => {
|
||||
if (active) setOptions(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active)
|
||||
setError(
|
||||
"Не удалось загрузить справочники. Измените поиск, чтобы повторить.",
|
||||
);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [search]);
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const form = new FormData(event.currentTarget);
|
||||
const data: ReportingEntryInput = {
|
||||
kind,
|
||||
project: form.get("project") ? Number(form.get("project")) : null,
|
||||
currency,
|
||||
date: String(form.get("date")),
|
||||
reference: String(form.get("reference")).trim(),
|
||||
description: String(form.get("description") || "").trim(),
|
||||
product: String(form.get("product") || "") || null,
|
||||
employee: form.get("employee") ? Number(form.get("employee")) : null,
|
||||
quantity: String(form.get("quantity") || "0"),
|
||||
amount: String(form.get("amount") || "0"),
|
||||
};
|
||||
try {
|
||||
await apiClient.createReportingEntry(data);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Не удалось сохранить запись.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const input = "app-input mt-1 w-full rounded-xl px-3 py-2.5";
|
||||
return (
|
||||
<form onSubmit={save} className="space-y-4 text-sm">
|
||||
<label className="block">
|
||||
Вид записи
|
||||
<select
|
||||
className="app-select mt-1 w-full rounded-xl px-3 py-2.5"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as ReportingKind);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{Object.entries(reportingKinds).map(([key, value]) => (
|
||||
<option key={key} value={key}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Проект
|
||||
<select
|
||||
className="app-select mt-1 w-full rounded-xl px-3 py-2.5"
|
||||
name="project"
|
||||
defaultValue={project === "unassigned" ? "" : project || ""}
|
||||
>
|
||||
<option value="">Без проекта</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.archived ? " (архив)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
Дата факта
|
||||
<input className={input} name="date" type="date" required />
|
||||
</label>
|
||||
<label>
|
||||
Документ / уникальный номер
|
||||
<input className={input} name="reference" maxLength={200} required />
|
||||
</label>
|
||||
</div>
|
||||
{(kind === "production" || kind === "purchase" || kind === "tax") && (
|
||||
<div className="space-y-3" key={kind}>
|
||||
<label className="block">
|
||||
Поиск в справочнике
|
||||
<input
|
||||
className={input}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={
|
||||
kind === "tax"
|
||||
? "Фамилия сотрудника"
|
||||
: "Название или артикул товара"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{kind === "tax" ? (
|
||||
<label className="block">
|
||||
Сотрудник
|
||||
<select
|
||||
className="app-select mt-1 w-full rounded-xl px-3 py-2.5"
|
||||
name="employee"
|
||||
required
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">Выберите сотрудника</option>
|
||||
{options?.employees.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="block">
|
||||
{kind === "production"
|
||||
? "Произведённый товар"
|
||||
: "Товар, для которого закуплены материалы (необязательно)"}
|
||||
<select
|
||||
className="app-select mt-1 w-full rounded-xl px-3 py-2.5"
|
||||
name="product"
|
||||
required={kind === "production"}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">
|
||||
{kind === "production"
|
||||
? "Выберите товар"
|
||||
: "Общие производственные закупки"}
|
||||
</option>
|
||||
{options?.products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<p className="app-text-muted text-xs">
|
||||
Показаны первые 100 результатов. Уточните поиск, если нужного
|
||||
значения нет.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{kind === "production" ? (
|
||||
<label className="block">
|
||||
Количество, ед.
|
||||
<input
|
||||
className={input}
|
||||
name="quantity"
|
||||
type="number"
|
||||
min="0.0001"
|
||||
step="0.0001"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="block">
|
||||
Сумма, {currency}
|
||||
<input
|
||||
className={input}
|
||||
name="amount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
Описание
|
||||
<input className={input} name="description" maxLength={500} />
|
||||
</label>
|
||||
<p className="app-text-muted text-xs">
|
||||
Указывайте подтверждённые факты. Закупки и движения денег учитываются
|
||||
отдельно от себестоимости продаж. Для исправления аннулируйте запись и
|
||||
создайте новую с другим номером.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="app-feedback-danger rounded-xl p-3">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary rounded-lg px-4 py-2.5"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
className="app-action-primary rounded-lg px-4 py-2.5"
|
||||
disabled={busy || !options}
|
||||
>
|
||||
{busy ? "Сохранение…" : "Сохранить факт"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportingJournal({
|
||||
params,
|
||||
options,
|
||||
onChanged,
|
||||
}: {
|
||||
params: ReportParams;
|
||||
options: AnalyticsOptions;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ReportingEntry[]>([]);
|
||||
const [entryCount, setEntryCount] = useState<number>();
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [voidEntry, setVoidEntry] = useState<ReportingEntry | null>(null);
|
||||
const [voidBusy, setVoidBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
apiClient
|
||||
.getReportingEntries({ ...params, page })
|
||||
.then((data) => {
|
||||
if (active) {
|
||||
setEntries(data.results);
|
||||
setEntryCount(data.count);
|
||||
setHasNext(Boolean(data.next));
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Не удалось загрузить журнал.",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [params, page, revision]);
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setRevision((v) => v + 1);
|
||||
onChanged();
|
||||
}
|
||||
async function cancelEntry() {
|
||||
if (!voidEntry) return;
|
||||
setVoidBusy(true);
|
||||
try {
|
||||
await apiClient.voidReportingEntry(voidEntry.id);
|
||||
setVoidEntry(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Не удалось аннулировать запись.",
|
||||
);
|
||||
} finally {
|
||||
setVoidBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<AnalyticsListSection title="Журнал фактов" count={entryCount} featured
|
||||
chart={<DatasetChart title="Журнал фактов"
|
||||
rows={entries.filter((entry) => !entry.voided_at).map((entry) => ({ id: entry.id,
|
||||
name: entry.reference || `${reportingKinds[entry.kind]} · ${entry.date}`,
|
||||
amount: entry.kind === "production" ? null : entry.amount,
|
||||
quantity: entry.kind === "production" ? entry.quantity : null }))}
|
||||
metrics={[{ key: "amount", additive: true, label: `Сумма, ${params.currency}` }, { key: "quantity", additive: true, label: "Выпуск, ед.", digits: 4 }]}
|
||||
labelKeys={["name"]} scope={`Учтённые записи текущей страницы (${entries.length} из ${entryCount ?? 0}); аннулированные исключены. Суммы и количество показаны отдельно.`} />}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{options.can_write && (
|
||||
<button
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Plus size={16} /> Добавить запись
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="app-feedback-danger mt-4 rounded-xl p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
<button onClick={reload} className="ml-3 underline">
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="app-text-muted py-10 text-center">Загружаем журнал…</p>
|
||||
) : (
|
||||
<div className="analytics-records-responsive mt-4">
|
||||
<div className="analytics-records-compact space-y-2">
|
||||
{entries.map((entry) => <AnalyticsRecordCard key={entry.id}
|
||||
title={entry.reference || reportingKinds[entry.kind]}
|
||||
subtitle={`${entry.date} · ${reportingKinds[entry.kind]}`}
|
||||
status={entry.voided_at ? "Аннулировано" : "Учтено"}
|
||||
summary={entry.kind === "production" ? `${formatMetric(entry.quantity, 4)} ед.` : `${formatMetric(entry.amount, 2)} ${params.currency}`}
|
||||
fields={[
|
||||
{ label: "Дата", value: entry.date },
|
||||
{ label: "Документ", value: entry.reference || "—" },
|
||||
{ label: "Вид", value: reportingKinds[entry.kind] },
|
||||
{ label: "Проект", value: entry.project_name || "Без проекта" },
|
||||
{ label: "Товар", value: entry.product_name || "—" },
|
||||
{ label: "Сотрудник", value: entry.employee_name || "—" },
|
||||
{ label: "Назначение", value: entry.description || "—" },
|
||||
{ label: "Количество", value: entry.kind === "production" ? formatMetric(entry.quantity, 4) : "—" },
|
||||
{ label: `Сумма, ${params.currency}`, value: entry.kind === "production" ? "—" : formatMetric(entry.amount, 2) },
|
||||
]}
|
||||
actions={!entry.voided_at && options.can_void ? <button type="button"
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-xs" onClick={() => setVoidEntry(entry)}>Аннулировать</button> : undefined}
|
||||
/>)}
|
||||
</div>
|
||||
<div className="analytics-records-table overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="app-text-muted">
|
||||
<tr>
|
||||
{[
|
||||
"Дата / документ",
|
||||
"Вид / назначение",
|
||||
"Количество",
|
||||
`Сумма, ${params.currency}`,
|
||||
"Статус",
|
||||
].map((label) => (
|
||||
<th className="px-3 py-3 font-medium" key={label}>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-t border-[var(--border-subtle)]"
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<p className="whitespace-nowrap">{entry.date}</p>
|
||||
<p className="app-text-muted mt-1 max-w-48 break-words">
|
||||
{entry.reference}
|
||||
</p>
|
||||
</td>
|
||||
<td className="min-w-56 px-3 py-3">
|
||||
<p>{reportingKinds[entry.kind]}</p>
|
||||
<p className="app-text-muted mt-1">
|
||||
{entry.project_name || "Без проекта"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1">
|
||||
{entry.product_name ||
|
||||
entry.employee_name ||
|
||||
entry.description ||
|
||||
"—"}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 tabular-nums">
|
||||
{entry.kind === "production"
|
||||
? formatMetric(entry.quantity, 4)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-3 py-3 tabular-nums">
|
||||
{entry.kind === "production"
|
||||
? "—"
|
||||
: formatMetric(entry.amount, 2)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{entry.voided_at ? (
|
||||
<span className="app-text-muted">Аннулировано</span>
|
||||
) : options.can_void ? (
|
||||
<button
|
||||
className="app-action-secondary rounded-lg px-3 py-2"
|
||||
onClick={() => setVoidEntry(entry)}
|
||||
>
|
||||
Аннулировать
|
||||
</button>
|
||||
) : (
|
||||
"Учтено"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!entries.length && (
|
||||
<p className="app-text-muted py-10 text-center text-sm">
|
||||
Записей пока нет. Добавьте подтверждённые факты, чтобы они
|
||||
появились в отчётах.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(page > 1 || hasNext) && (
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
|
||||
disabled={page === 1 || loading}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setPage((p) => p - 1);
|
||||
}}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
|
||||
disabled={!hasNext || loading}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setPage((p) => p + 1);
|
||||
}}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
title="Добавить факт для аналитики"
|
||||
>
|
||||
<EntryForm
|
||||
currency={params.currency}
|
||||
projects={options.projects}
|
||||
project={params.project}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={Boolean(voidEntry)}
|
||||
onClose={() => {
|
||||
if (!voidBusy) setVoidEntry(null);
|
||||
}}
|
||||
title="Аннулировать запись?"
|
||||
size="sm"
|
||||
>
|
||||
<p className="text-sm">
|
||||
Запись «{voidEntry?.reference}» останется в журнале и перестанет
|
||||
учитываться в отчётах.
|
||||
</p>
|
||||
<button
|
||||
disabled={voidBusy}
|
||||
className="app-action-primary mt-5 rounded-lg px-4 py-2.5 text-sm"
|
||||
onClick={cancelEntry}
|
||||
>
|
||||
{voidBusy ? "Сохранение…" : "Аннулировать"}
|
||||
</button>
|
||||
</Modal>
|
||||
</AnalyticsListSection>
|
||||
);
|
||||
}
|
||||
@ -39,6 +39,8 @@ const statusLabels: Record<IntegrationConnection["status"], string> = {
|
||||
};
|
||||
|
||||
const stageLabels: Record<Exclude<IntegrationSyncRun["stage"], "">, string> = {
|
||||
projects: "Проекты",
|
||||
project_payments: "Расходы проектов",
|
||||
catalog: "Каталог",
|
||||
images: "Изображения",
|
||||
variant_links: "Связи вариантов",
|
||||
|
||||
@ -4,12 +4,12 @@ import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import type { PayrollAdminTab } from "@/lib/payroll-admin";
|
||||
|
||||
export type PayrollFinanceTab = "statement" | "work" | "management";
|
||||
export type PayrollFinanceTab = "statement" | "work" | "management" | "analytics";
|
||||
|
||||
const PAYROLL_TAB_CHANGE_EVENT = "payroll-tab-change";
|
||||
const FINANCE_TAB_STORAGE_PREFIX = "finances.payroll.active-tab";
|
||||
const ADMIN_TAB_STORAGE_PREFIX = "finances.payroll.admin.active-tab";
|
||||
const ALL_FINANCE_TABS: ReadonlySet<string> = new Set(["statement", "work", "management"]);
|
||||
const ALL_FINANCE_TABS: ReadonlySet<string> = new Set(["statement", "work", "management", "analytics"]);
|
||||
const EMPLOYEE_FINANCE_TABS: ReadonlySet<string> = new Set(["statement", "work"]);
|
||||
const ADMIN_TABS: ReadonlySet<string> = new Set([
|
||||
"readiness",
|
||||
@ -20,6 +20,13 @@ const ADMIN_TABS: ReadonlySet<string> = new Set([
|
||||
"approval",
|
||||
]);
|
||||
|
||||
export type AnalyticsTab = "projects" | "sales" | "people" | "production" | "journal";
|
||||
const ANALYTICS_TABS: ReadonlySet<string> = new Set(["projects", "sales", "people", "production", "journal"]);
|
||||
|
||||
export function useAnalyticsTab(userId?: number | null) {
|
||||
return useStoredPayrollTab<AnalyticsTab>({ userId, storagePrefix: "finances.analytics.active-tab", allowedTabs: ANALYTICS_TABS, fallback: "sales" });
|
||||
}
|
||||
|
||||
function useStoredPayrollTab<T extends string>({
|
||||
userId,
|
||||
storagePrefix,
|
||||
|
||||
70
frontend/src/lib/analytics.test.js
Normal file
70
frontend/src/lib/analytics.test.js
Normal file
@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildAnalyticsCsv, csvCell, formatMetric, prepareAnalyticsChart } from "./analytics.ts";
|
||||
|
||||
test("unknown metrics remain missing while actual zero is visible", () => {
|
||||
assert.equal(formatMetric(null), "—");
|
||||
assert.equal(formatMetric(undefined), "—");
|
||||
assert.equal(formatMetric("invalid"), "—");
|
||||
assert.equal(formatMetric("0.0000"), "0");
|
||||
});
|
||||
|
||||
test("monthly charts preserve missing values and sort dates without turning gaps into zero", () => {
|
||||
const chart = prepareAnalyticsChart([
|
||||
{ id: "2026-03", amount: "0" }, { id: "2026-01", amount: "12.5" },
|
||||
{ id: "2026-02", amount: null },
|
||||
], "amount", ["id"]);
|
||||
assert.equal(chart.monthly, true);
|
||||
assert.equal(chart.missing, 1);
|
||||
assert.deepEqual(chart.points.map((point) => point.value), [12.5, null, 0]);
|
||||
});
|
||||
|
||||
test("ranked charts keep negative amounts, reject non-values and rank across all rows", () => {
|
||||
const rows = Array.from({ length: 25 }, (_, i) => ({ id: i, name: `Row ${i}`, amount: i }));
|
||||
rows.push({ id: 25, name: "Refund", amount: -100 });
|
||||
const chart = prepareAnalyticsChart([...rows,
|
||||
{ id: "missing", amount: null }, { id: "blank", amount: "" },
|
||||
{ id: "invalid", amount: "NaN" }, { id: "flag", amount: false },
|
||||
], "amount", ["name"]);
|
||||
assert.equal(chart.total, 30);
|
||||
assert.equal(chart.missing, 4);
|
||||
assert.equal(chart.points.length, 7);
|
||||
assert.equal(chart.groupedCount, 0);
|
||||
assert.equal(chart.points[0].value, -100);
|
||||
assert.equal(chart.points[1].value, 24);
|
||||
});
|
||||
test("CSV neutralizes spreadsheet formulas, quotes multiline names and preserves negative amounts", () => {
|
||||
assert.equal(csvCell("=HYPERLINK(1)"), '"\'=HYPERLINK(1)"');
|
||||
assert.equal(csvCell("-123.45"), '"-123.45"');
|
||||
const csv = buildAnalyticsCsv(
|
||||
[{ id: "1", name: 'A;"B"\nC', value: null }],
|
||||
[
|
||||
{ key: "name", label: "Клиент" },
|
||||
{ key: "value", label: "Прибыль" },
|
||||
],
|
||||
);
|
||||
assert.ok(csv.startsWith("\uFEFF"));
|
||||
assert.ok(csv.includes('"A;""B""\nC";""'));
|
||||
});
|
||||
|
||||
|
||||
test("additive rankings combine the remainder without losing signed totals or including unknowns", () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i, name: `Row ${i}`, amount: i % 2 ? -i : i }));
|
||||
const chart = prepareAnalyticsChart([...rows, { id: "unknown", amount: null }], "amount", ["name"], true);
|
||||
assert.equal(chart.points.length, 7);
|
||||
assert.equal(chart.groupedCount, 6);
|
||||
assert.equal(chart.missing, 1);
|
||||
assert.equal(chart.points.at(-1).label, "Прочее");
|
||||
assert.equal(chart.points.at(-1).value, -3);
|
||||
assert.equal(chart.points.reduce((sum, point) => sum + point.value, 0), rows.reduce((sum, row) => sum + row.amount, 0));
|
||||
});
|
||||
|
||||
test("small rankings and monthly series never add an unnecessary remainder", () => {
|
||||
const rows = Array.from({ length: 7 }, (_, i) => ({ id: i, amount: i }));
|
||||
const chart = prepareAnalyticsChart(rows, "amount", ["id"], true);
|
||||
assert.equal(chart.points.length, 7);
|
||||
assert.equal(chart.groupedCount, 0);
|
||||
const months = prepareAnalyticsChart(Array.from({ length: 12 }, (_, i) => ({ id: `2026-${String(i + 1).padStart(2, "0")}`, amount: i })), "amount", ["id"], true);
|
||||
assert.equal(months.points.length, 12);
|
||||
assert.equal(months.groupedCount, 0);
|
||||
});
|
||||
77
frontend/src/lib/analytics.ts
Normal file
77
frontend/src/lib/analytics.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type { AnalyticsRow, MetricValue } from "@/types/analytics";
|
||||
|
||||
export const reportingKinds = {
|
||||
production: "Выпуск продукции",
|
||||
purchase: "Закупка для производства",
|
||||
tax: "Уплаченные налоги сотрудника",
|
||||
overhead: "Общие расходы",
|
||||
cash_in: "Поступление денег",
|
||||
cash_out: "Выплата денег",
|
||||
};
|
||||
|
||||
export function formatMetric(
|
||||
value: MetricValue | undefined | boolean,
|
||||
digits = 0,
|
||||
): string {
|
||||
if (value === null || value === undefined || typeof value === "boolean")
|
||||
return "—";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number)
|
||||
? new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits }).format(
|
||||
number,
|
||||
)
|
||||
: "—";
|
||||
}
|
||||
|
||||
export function prepareAnalyticsChart(rows: AnalyticsRow[], key: string, labelKeys: string[], additive = false) {
|
||||
const monthly = rows.length > 0 && rows.every((row) => /^\d{4}-\d{2}$/.test(String(row.id)));
|
||||
const points = rows.map((row) => {
|
||||
const raw = row[key];
|
||||
const value = raw === null || raw === undefined || typeof raw === "boolean" || raw === ""
|
||||
? null : Number(raw);
|
||||
return {
|
||||
id: String(row.id),
|
||||
label: monthly ? String(row.id) : labelKeys.map((field) => String(row[field] ?? "")).filter(Boolean).join(" · ") || String(row.id),
|
||||
value: value !== null && Number.isFinite(value) ? value : null,
|
||||
};
|
||||
});
|
||||
const missing = points.filter((point) => point.value === null).length;
|
||||
const ranked = points.filter((point) => point.value !== null)
|
||||
.sort((a, b) => Math.abs(b.value!) - Math.abs(a.value!));
|
||||
const groupedCount = !monthly && additive && ranked.length > 7 ? ranked.length - 6 : 0;
|
||||
const visible = ranked.slice(0, groupedCount ? 6 : 7);
|
||||
if (groupedCount) visible.push({
|
||||
id: "analytics-other",
|
||||
label: "Прочее",
|
||||
value: ranked.slice(6).reduce((sum, point) => sum + point.value!, 0),
|
||||
});
|
||||
return {
|
||||
monthly,
|
||||
missing,
|
||||
total: rows.length,
|
||||
groupedCount,
|
||||
points: monthly
|
||||
? points.sort((a, b) => a.id.localeCompare(b.id))
|
||||
: visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function csvCell(value: unknown): string {
|
||||
let text = value == null ? "" : String(value);
|
||||
if (/^[\s]*[=+@-]/.test(text) && !/^-?\d+(\.\d+)?$/.test(text))
|
||||
text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function buildAnalyticsCsv(
|
||||
rows: AnalyticsRow[],
|
||||
columns: { key: string; label: string }[],
|
||||
): string {
|
||||
return (
|
||||
"\uFEFF" +
|
||||
[
|
||||
columns.map((c) => csvCell(c.label)).join(";"),
|
||||
...rows.map((row) => columns.map((c) => csvCell(row[c.key])).join(";")),
|
||||
].join("\r\n")
|
||||
);
|
||||
}
|
||||
47
frontend/src/lib/api/analytics.ts
Normal file
47
frontend/src/lib/api/analytics.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AnalyticsOptions,
|
||||
AnalyticsReport,
|
||||
ReportParams,
|
||||
ReportingEntry,
|
||||
ReportingEntryInput,
|
||||
ProjectPayment,
|
||||
} from "@/types/analytics";
|
||||
import { buildQuery, type RequestFn } from "./utils";
|
||||
|
||||
export function createAnalyticsApi(request: RequestFn) {
|
||||
return {
|
||||
getProjectPayments: (
|
||||
params: ReportParams & { page?: number },
|
||||
): Promise<{
|
||||
count: number;
|
||||
next: string | null;
|
||||
results: ProjectPayment[];
|
||||
}> =>
|
||||
request(
|
||||
`/api/v1/finance/analytics/project-payments/${buildQuery(params)}`,
|
||||
),
|
||||
getAnalyticsReport: (params: ReportParams): Promise<AnalyticsReport> =>
|
||||
request(`/api/v1/finance/analytics/report/${buildQuery(params)}`),
|
||||
getAnalyticsOptions: (search = ""): Promise<AnalyticsOptions> =>
|
||||
request(`/api/v1/finance/analytics/options/${buildQuery({ search })}`),
|
||||
getReportingEntries: (
|
||||
params: ReportParams & { page?: number },
|
||||
): Promise<{
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: ReportingEntry[];
|
||||
}> => request(`/api/v1/finance/analytics/entries/${buildQuery(params)}`),
|
||||
createReportingEntry: (
|
||||
data: ReportingEntryInput,
|
||||
): Promise<ReportingEntry> =>
|
||||
request("/api/v1/finance/analytics/entries/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
voidReportingEntry: (id: number): Promise<ReportingEntry> =>
|
||||
request(`/api/v1/finance/analytics/entries/${id}/void/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -18,6 +18,7 @@ import { createGuestsApi } from './guests';
|
||||
import { createTasksApi } from './tasks';
|
||||
import { createFinanceApi } from './finance';
|
||||
import { createCommerceApi } from './commerce';
|
||||
import { createAnalyticsApi } from './analytics';
|
||||
|
||||
const base = new ApiClientBase();
|
||||
const req = base.request.bind(base);
|
||||
@ -39,6 +40,7 @@ export const apiClient = Object.assign(base, {
|
||||
...createTasksApi(req, raw),
|
||||
...createFinanceApi(req),
|
||||
...createCommerceApi(req),
|
||||
...createAnalyticsApi(req),
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
|
||||
102
frontend/src/types/analytics.ts
Normal file
102
frontend/src/types/analytics.ts
Normal file
@ -0,0 +1,102 @@
|
||||
export type MetricValue = string | number | null;
|
||||
export type AnalyticsRow = {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
[key: string]: MetricValue | boolean | undefined;
|
||||
};
|
||||
export type ReportParams = {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
currency: string;
|
||||
source?: string;
|
||||
section?: string;
|
||||
project?: string;
|
||||
};
|
||||
export type AnalyticsReport = {
|
||||
projects?: {
|
||||
summary: AnalyticsRow;
|
||||
rows: AnalyticsRow[];
|
||||
monthly: AnalyticsRow[];
|
||||
categories: AnalyticsRow[];
|
||||
};
|
||||
period: { from: string; to: string };
|
||||
currency: string;
|
||||
generated_at: string;
|
||||
can_write: boolean;
|
||||
sales?: {
|
||||
summary: AnalyticsRow;
|
||||
products: AnalyticsRow[];
|
||||
channels: AnalyticsRow[];
|
||||
customers: AnalyticsRow[];
|
||||
product_channels: AnalyticsRow[];
|
||||
monthly: AnalyticsRow[];
|
||||
quality: Record<string, number>;
|
||||
cost_breakdown: Record<string, string>;
|
||||
};
|
||||
production?: {
|
||||
products: AnalyticsRow[];
|
||||
monthly: AnalyticsRow[];
|
||||
product_channels: AnalyticsRow[];
|
||||
totals: Record<string, string>;
|
||||
record_counts: Record<string, number>;
|
||||
};
|
||||
people?: {
|
||||
summary: AnalyticsRow;
|
||||
employees: AnalyticsRow[];
|
||||
monthly: AnalyticsRow[];
|
||||
};
|
||||
};
|
||||
export type ReportingKind =
|
||||
| "production"
|
||||
| "purchase"
|
||||
| "tax"
|
||||
| "overhead"
|
||||
| "cash_in"
|
||||
| "cash_out";
|
||||
export type ReportingEntryInput = {
|
||||
project?: number | null;
|
||||
kind: ReportingKind;
|
||||
date: string;
|
||||
reference: string;
|
||||
description: string;
|
||||
currency: string;
|
||||
amount: string;
|
||||
quantity: string;
|
||||
product: string | null;
|
||||
employee: number | null;
|
||||
};
|
||||
export type ReportingEntry = ReportingEntryInput & {
|
||||
project_name: string;
|
||||
id: number;
|
||||
product_name: string;
|
||||
employee_name: string;
|
||||
created_at: string;
|
||||
voided_at: string | null;
|
||||
};
|
||||
export type AnalyticsOptions = {
|
||||
projects: {
|
||||
id: number;
|
||||
name: string;
|
||||
source_id: number;
|
||||
archived: boolean;
|
||||
}[];
|
||||
products: { id: string; name: string }[];
|
||||
employees: { id: number; name: string }[];
|
||||
sources: { id: number; name: string }[];
|
||||
can_write: boolean;
|
||||
can_void: boolean;
|
||||
};
|
||||
|
||||
export type ProjectPayment = {
|
||||
id: number;
|
||||
project_name: string;
|
||||
source_name: string;
|
||||
document_type: string;
|
||||
number: string;
|
||||
description: string;
|
||||
expense_category: string;
|
||||
payee: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
occurred_at: string;
|
||||
};
|
||||
@ -34,6 +34,8 @@ export type IntegrationSyncRun = {
|
||||
status: "queued" | "running" | "success" | "error";
|
||||
task_id: string;
|
||||
stage:
|
||||
| "projects"
|
||||
| "project_payments"
|
||||
| ""
|
||||
| "catalog"
|
||||
| "images"
|
||||
@ -157,6 +159,8 @@ export type CommerceSale = {
|
||||
number: string;
|
||||
event_type: "sale" | "return";
|
||||
channel: string;
|
||||
customer_external_id: string;
|
||||
customer_name: string;
|
||||
status: string;
|
||||
counts_as_sale: boolean;
|
||||
currency: string;
|
||||
@ -231,6 +235,8 @@ export type CommerceSaleInput = {
|
||||
number?: string;
|
||||
event_type?: CommerceSale["event_type"];
|
||||
channel?: string;
|
||||
customer_external_id?: string;
|
||||
customer_name?: string;
|
||||
status?: string;
|
||||
counts_as_sale?: boolean;
|
||||
currency?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user