Add project analytics and improve commerce integrations

This commit is contained in:
lizerk 2026-09-07 12:23:15 +03:00
parent 76c9be260d
commit cd64ee4ef7
59 changed files with 5457 additions and 31 deletions

3
.gitignore vendored
View File

@ -207,3 +207,6 @@ dump.rdb
.playwright-cli/
.ruff_cache/
.pytest_cache/
# Local planning documents and source materials
/project-planning/

View File

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

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

View File

@ -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(),

View File

@ -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)
# Группируем и форматируем результаты

View File

@ -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
View 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

View File

@ -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:

View File

@ -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}.")
)

View File

@ -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="Клиент"),
),
]

View 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)]

View File

@ -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'),
),
]

View File

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

View File

@ -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(

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

View File

@ -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(
"Учитывать в продажах",

View File

@ -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", "Связи вариантов"

View 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

View File

@ -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,

View File

@ -0,0 +1 @@
"""Management reporting over commerce, payroll and explicitly recorded facts."""

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

View 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,
},
}

View 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())
],
}

View 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"],
}

View 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",
),
],
},
),
]

View 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'),
),
]

View File

@ -26,6 +26,7 @@ from .enums import (
PayrollRunStatus,
VacationPayInstructionStatus,
)
from .analytics.models import ReportingEntry # noqa: F401
currency_validator = RegexValidator(
regex=r"^[A-Z]{3}$",

View File

@ -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

View 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()

View 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

View 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()

View File

@ -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-контракты:

View File

@ -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 транзакции, а заранее сформированный

View 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).

View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function CompanyAnalyticsPage() {
redirect("/finances?tab=analytics");
}

View File

@ -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" />
) : (

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Интерфейс модуля отложен. Импорт и API продолжают обслуживать аналитику.
export default function ProductsLayout() {
redirect("/");
}

View File

@ -41,7 +41,6 @@ const modelOrder: SearchModelType[] = [
"schedule_event",
"procurement_request",
"equipment",
"product",
"task_board",
"task",
"guest",

View File

@ -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 },

View 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>
</>;
}

View 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>
</>}
</>;
}

View 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>;
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -39,6 +39,8 @@ const statusLabels: Record<IntegrationConnection["status"], string> = {
};
const stageLabels: Record<Exclude<IntegrationSyncRun["stage"], "">, string> = {
projects: "Проекты",
project_payments: "Расходы проектов",
catalog: "Каталог",
images: "Изображения",
variant_links: "Связи вариантов",

View File

@ -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,

View 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);
});

View 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")
);
}

View 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",
}),
};
}

View File

@ -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;

View 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;
};

View File

@ -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;