diff --git a/backend/api/v1/commerce/__init__.py b/backend/api/v1/commerce/__init__.py new file mode 100644 index 00000000..314ffc89 --- /dev/null +++ b/backend/api/v1/commerce/__init__.py @@ -0,0 +1 @@ +"""REST API for product-centric commerce analytics.""" diff --git a/backend/api/v1/commerce/permissions.py b/backend/api/v1/commerce/permissions.py new file mode 100644 index 00000000..cb6eb973 --- /dev/null +++ b/backend/api/v1/commerce/permissions.py @@ -0,0 +1,30 @@ +from rest_framework import permissions + + +class CommerceAccessPermission(permissions.BasePermission): + """All employees may read; writes require staff or Django model permission.""" + + action_to_permission = { + "create": "add", + "update": "change", + "partial_update": "change", + "destroy": "delete", + } + + def has_permission(self, request, view): + user = request.user + if not user or not user.is_authenticated: + return False + if request.method in permissions.SAFE_METHODS: + return True + if user.is_staff: + return True + + action = getattr(view, "action", None) + operation = self.action_to_permission.get(action) + queryset = getattr(view, "queryset", None) + model = getattr(queryset, "model", None) + if not operation or model is None: + return False + opts = model._meta + return user.has_perm(f"{opts.app_label}.{operation}_{opts.model_name}") diff --git a/backend/api/v1/commerce/serializers.py b/backend/api/v1/commerce/serializers.py new file mode 100644 index 00000000..d7787dc5 --- /dev/null +++ b/backend/api/v1/commerce/serializers.py @@ -0,0 +1,180 @@ +from rest_framework import serializers + +from commerce.models import ( + Cost, + Product, + ProductBinding, + Sale, + SaleLine, + SourceSystem, + StockSnapshot, +) + + +class SourceSystemSerializer(serializers.ModelSerializer): + class Meta: + model = SourceSystem + fields = [ + "id", + "code", + "name", + "is_active", + "metadata", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class ProductBindingSerializer(serializers.ModelSerializer): + source_code = serializers.CharField(source="source.code", read_only=True) + + class Meta: + model = ProductBinding + fields = [ + "id", + "product", + "source", + "source_code", + "external_id", + "external_sku", + "is_primary", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class ProductSerializer(serializers.ModelSerializer): + bindings = ProductBindingSerializer(many=True, read_only=True) + variants_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Product + fields = [ + "id", + "parent", + "kind", + "sku", + "name", + "barcode", + "description", + "is_active", + "variants_count", + "bindings", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class SaleLineSerializer(serializers.ModelSerializer): + net_amount = serializers.DecimalField( + max_digits=18, decimal_places=2, read_only=True + ) + + class Meta: + model = SaleLine + fields = [ + "id", + "product", + "external_id", + "external_product_id", + "external_sku", + "name", + "quantity", + "gross_amount", + "discount_amount", + "net_amount", + "cost_amount", + "metadata", + ] + + +class SaleSerializer(serializers.ModelSerializer): + source_code = serializers.CharField(source="source.code", read_only=True) + lines = SaleLineSerializer(many=True, read_only=True) + + class Meta: + model = Sale + fields = [ + "id", + "source", + "source_code", + "source_record", + "external_id", + "number", + "event_type", + "channel", + "status", + "counts_as_sale", + "currency", + "occurred_at", + "source_updated_at", + "lines", + "created_at", + "updated_at", + ] + + +class CostSerializer(serializers.ModelSerializer): + source_code = serializers.CharField(source="source.code", read_only=True) + + class Meta: + model = Cost + fields = [ + "id", + "source", + "source_code", + "product", + "sale_line", + "external_id", + "channel", + "cost_type", + "amount", + "currency", + "occurred_at", + "metadata", + "created_at", + ] + + +class StockSnapshotSerializer(serializers.ModelSerializer): + source_code = serializers.CharField(source="source.code", read_only=True) + available = serializers.DecimalField( + max_digits=18, decimal_places=4, read_only=True + ) + + class Meta: + model = StockSnapshot + fields = [ + "id", + "product", + "source", + "source_code", + "warehouse_external_id", + "warehouse_name", + "quantity", + "reserved", + "available", + "in_transit", + "captured_at", + "metadata", + "created_at", + ] + + +class ProductMetricsQuerySerializer(serializers.Serializer): + date_from = serializers.DateTimeField(required=False) + date_to = serializers.DateTimeField(required=False) + + def validate(self, attrs): + if ( + attrs.get("date_from") + and attrs.get("date_to") + and attrs["date_from"] > attrs["date_to"] + ): + raise serializers.ValidationError( + {"date_to": "Дата окончания должна быть не раньше даты начала."} + ) + return attrs diff --git a/backend/api/v1/commerce/urls.py b/backend/api/v1/commerce/urls.py new file mode 100644 index 00000000..fd5cd39b --- /dev/null +++ b/backend/api/v1/commerce/urls.py @@ -0,0 +1,22 @@ +from rest_framework.routers import DefaultRouter + +from .viewsets import ( + CostViewSet, + ProductBindingViewSet, + ProductViewSet, + SaleViewSet, + SourceSystemViewSet, + StockSnapshotViewSet, +) + +app_name = "commerce" + +router = DefaultRouter() +router.register(r"products", ProductViewSet, basename="product") +router.register(r"sources", SourceSystemViewSet, basename="source") +router.register(r"product-bindings", ProductBindingViewSet, basename="productbinding") +router.register(r"sales", SaleViewSet, basename="sale") +router.register(r"costs", CostViewSet, basename="cost") +router.register(r"stock-snapshots", StockSnapshotViewSet, basename="stocksnapshot") + +urlpatterns = router.urls diff --git a/backend/api/v1/commerce/viewsets.py b/backend/api/v1/commerce/viewsets.py new file mode 100644 index 00000000..1247a45b --- /dev/null +++ b/backend/api/v1/commerce/viewsets.py @@ -0,0 +1,121 @@ +from django.db.models import Count +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from commerce.models import ( + Cost, + Product, + ProductBinding, + Sale, + SourceSystem, + StockSnapshot, +) +from commerce.services import get_product_metrics + +from .permissions import CommerceAccessPermission +from .serializers import ( + CostSerializer, + ProductBindingSerializer, + ProductMetricsQuerySerializer, + ProductSerializer, + SaleSerializer, + SourceSystemSerializer, + StockSnapshotSerializer, +) + + +class SourceSystemViewSet(viewsets.ModelViewSet): + queryset = SourceSystem.objects.all() + serializer_class = SourceSystemSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["code", "name"] + ordering_fields = ["code", "name", "created_at"] + ordering = ["name"] + + +class ProductViewSet(viewsets.ModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_fields = ["kind", "is_active", "parent"] + search_fields = ["sku", "name", "barcode", "bindings__external_sku"] + ordering_fields = ["sku", "name", "created_at", "updated_at"] + ordering = ["name", "sku"] + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("parent") + .prefetch_related("bindings__source") + .annotate(variants_count=Count("variants", distinct=True)) + .distinct() + ) + + @action(detail=True, methods=["get"]) + def analytics(self, request, pk=None): + query = ProductMetricsQuerySerializer(data=request.query_params) + query.is_valid(raise_exception=True) + product = self.get_object() + return Response(get_product_metrics(product, **query.validated_data)) + + +class ProductBindingViewSet(viewsets.ModelViewSet): + queryset = ProductBinding.objects.select_related("product", "source") + serializer_class = ProductBindingSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_fields = ["product", "source", "is_primary"] + search_fields = ["external_id", "external_sku", "product__sku", "product__name"] + + +class SaleViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Sale.objects.select_related("source", "source_record").prefetch_related( + "lines__product" + ) + serializer_class = SaleSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_fields = [ + "source", + "event_type", + "channel", + "status", + "counts_as_sale", + "currency", + ] + search_fields = ["external_id", "number", "lines__external_sku", "lines__name"] + ordering_fields = ["occurred_at", "created_at", "updated_at"] + ordering = ["-occurred_at"] + + +class CostViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Cost.objects.select_related("source", "product", "sale_line") + serializer_class = CostSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["source", "product", "channel", "cost_type", "currency"] + ordering_fields = ["occurred_at", "amount", "created_at"] + ordering = ["-occurred_at"] + + +class StockSnapshotViewSet(viewsets.ReadOnlyModelViewSet): + queryset = StockSnapshot.objects.select_related("source", "product") + serializer_class = StockSnapshotSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["source", "product", "warehouse_external_id"] + ordering_fields = ["captured_at", "quantity", "created_at"] + ordering = ["-captured_at"] diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 4b938b40..cbceeddc 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -179,6 +179,8 @@ urlpatterns = [ path("notifications/", include("notifications.api.urls")), # Procurement API path("procurement/", include("api.v1.procurement.urls")), + # Product-centric commerce analytics API + path("commerce/", include("api.v1.commerce.urls")), # Finance / payroll API path("finance/", include("api.v1.finance.urls")), # Tasks API diff --git a/backend/commerce/README.md b/backend/commerce/README.md new file mode 100644 index 00000000..6173912d --- /dev/null +++ b/backend/commerce/README.md @@ -0,0 +1,49 @@ +# Commerce analytics core + +`commerce` is the product-centric analytics boundary of EUSRR. It does not +replace an operational CRM or warehouse. External and future internal systems +all supply records to the same canonical product model. + +## Stable model + +- `Product` is one sellable or stockable SKU. `parent` groups variants. +- `SourceRecord` retains the latest raw payload with source provenance. +- `ProductBinding` maps a source product/offer identifier to `Product`. +- `Sale` and `SaleLine` contain normalized product-level sales facts. +- `Cost` contains commissions, delivery, advertising and other expenses. +- `StockSnapshot` contains point-in-time stock observations by source and + warehouse. + +Money is never aggregated across currencies. Returns are represented as a +`Sale` with `event_type="return"`; line values remain positive and analytics +applies the sign. + +## Integration contract + +An adapter subclasses `commerce.integrations.SourceAdapter`, handles API +pagination internally and yields `ExternalRecord` objects. Register it in +`adapter_registry`, then invoke the Celery task `commerce.sync_source`. + +Adapters must not store credentials in `SourceSystem.metadata`; secrets belong +in environment-backed configuration or a dedicated encrypted credential +store. + +Source-specific code must not create alternative product models. It stores the +raw payload first and then resolves a product through `ProductBinding` or +`commerce.services.match_product` before writing normalized facts. + +## API + +The authenticated API is mounted at `/api/v1/commerce/`: + +- `products/` +- `products/{id}/analytics/` +- `sources/` +- `product-bindings/` +- `sales/` +- `costs/` +- `stock-snapshots/` + +All employees may read. Writes to the canonical catalogue and bindings require +staff status or the corresponding Django model permission. Normalized fact +endpoints are read-only; integrations write them through application services. diff --git a/backend/commerce/__init__.py b/backend/commerce/__init__.py new file mode 100644 index 00000000..b2824967 --- /dev/null +++ b/backend/commerce/__init__.py @@ -0,0 +1 @@ +"""Product-centric commerce analytics domain.""" diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py new file mode 100644 index 00000000..2e83a206 --- /dev/null +++ b/backend/commerce/admin.py @@ -0,0 +1,118 @@ +from django.contrib import admin + +from .models import ( + Cost, + Product, + ProductBinding, + Sale, + SaleLine, + SourceRecord, + SourceSystem, + StockSnapshot, +) + + +class ProductBindingInline(admin.TabularInline): + model = ProductBinding + extra = 0 + autocomplete_fields = ["source"] + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ["sku", "name", "kind", "parent", "is_active", "updated_at"] + list_filter = ["kind", "is_active"] + search_fields = ["sku", "name", "barcode", "bindings__external_sku"] + autocomplete_fields = ["parent"] + inlines = [ProductBindingInline] + + +@admin.register(SourceSystem) +class SourceSystemAdmin(admin.ModelAdmin): + list_display = ["code", "name", "is_active", "updated_at"] + list_filter = ["is_active"] + search_fields = ["code", "name"] + + +@admin.register(SourceRecord) +class SourceRecordAdmin(admin.ModelAdmin): + list_display = ["source", "entity_type", "external_id", "received_at"] + list_filter = ["source", "entity_type"] + search_fields = ["external_id"] + readonly_fields = ["payload_hash", "received_at", "created_at"] + autocomplete_fields = ["source"] + + +@admin.register(ProductBinding) +class ProductBindingAdmin(admin.ModelAdmin): + list_display = ["product", "source", "external_id", "external_sku", "is_primary"] + list_filter = ["source", "is_primary"] + search_fields = ["product__sku", "external_id", "external_sku"] + autocomplete_fields = ["product", "source"] + + +class SaleLineInline(admin.TabularInline): + model = SaleLine + extra = 0 + autocomplete_fields = ["product"] + + +@admin.register(SaleLine) +class SaleLineAdmin(admin.ModelAdmin): + list_display = ["sale", "product", "external_sku", "quantity", "gross_amount"] + search_fields = [ + "external_id", + "external_sku", + "name", + "sale__number", + "product__sku", + ] + autocomplete_fields = ["sale", "product"] + + +@admin.register(Sale) +class SaleAdmin(admin.ModelAdmin): + list_display = [ + "number", + "source", + "event_type", + "channel", + "status", + "counts_as_sale", + "occurred_at", + ] + list_filter = ["source", "event_type", "channel", "counts_as_sale", "currency"] + search_fields = ["number", "external_id", "lines__external_sku"] + autocomplete_fields = ["source", "source_record"] + inlines = [SaleLineInline] + + +@admin.register(Cost) +class CostAdmin(admin.ModelAdmin): + list_display = [ + "cost_type", + "product", + "source", + "channel", + "amount", + "currency", + "occurred_at", + ] + list_filter = ["cost_type", "source", "channel", "currency"] + search_fields = ["external_id", "product__sku", "product__name"] + autocomplete_fields = ["source", "product", "sale_line"] + + +@admin.register(StockSnapshot) +class StockSnapshotAdmin(admin.ModelAdmin): + list_display = [ + "product", + "source", + "warehouse_name", + "quantity", + "reserved", + "captured_at", + ] + list_filter = ["source", "warehouse_name"] + search_fields = ["product__sku", "product__name", "warehouse_external_id"] + autocomplete_fields = ["product", "source"] diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py new file mode 100644 index 00000000..db7b5261 --- /dev/null +++ b/backend/commerce/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CommerceConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "commerce" + verbose_name = "Товарная аналитика" diff --git a/backend/commerce/integrations/__init__.py b/backend/commerce/integrations/__init__.py new file mode 100644 index 00000000..c027ded9 --- /dev/null +++ b/backend/commerce/integrations/__init__.py @@ -0,0 +1,11 @@ +"""Adapters that translate external APIs into source records.""" + +from .base import ExternalRecord, SourceAdapter +from .registry import adapter_registry, sync_registered_source + +__all__ = [ + "ExternalRecord", + "SourceAdapter", + "adapter_registry", + "sync_registered_source", +] diff --git a/backend/commerce/integrations/base.py b/backend/commerce/integrations/base.py new file mode 100644 index 00000000..d3216efa --- /dev/null +++ b/backend/commerce/integrations/base.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True) +class ExternalRecord: + entity_type: str + external_id: str + payload: dict[str, Any] + source_updated_at: datetime | None = None + + +class SourceAdapter(ABC): + """Minimal contract implemented by each API or file-import adapter.""" + + source_code: str + source_name: str + + @abstractmethod + def iter_records(self, *, entity_type: str) -> Iterable[ExternalRecord]: + """Yield source records, handling pagination inside the adapter.""" + + def supported_entity_types(self) -> set[str]: + return {"product", "sale", "stock", "cost"} diff --git a/backend/commerce/integrations/registry.py b/backend/commerce/integrations/registry.py new file mode 100644 index 00000000..a6012691 --- /dev/null +++ b/backend/commerce/integrations/registry.py @@ -0,0 +1,55 @@ +from commerce.models import SourceSystem +from commerce.services import IngestResult, ingest_source_record + +from .base import SourceAdapter + + +class AdapterRegistry: + def __init__(self): + self._adapters: dict[str, SourceAdapter] = {} + + def register(self, adapter: SourceAdapter): + code = adapter.source_code.strip().lower() + if not code: + raise ValueError("Adapter source_code must not be empty") + if code in self._adapters: + raise ValueError(f"Adapter '{code}' is already registered") + self._adapters[code] = adapter + return adapter + + def get(self, source_code: str) -> SourceAdapter: + try: + return self._adapters[source_code.strip().lower()] + except KeyError as exc: + raise LookupError(f"Adapter '{source_code}' is not registered") from exc + + def codes(self) -> tuple[str, ...]: + return tuple(sorted(self._adapters)) + + +adapter_registry = AdapterRegistry() + + +def sync_registered_source(source_code: str, *, entity_type: str) -> list[IngestResult]: + adapter = adapter_registry.get(source_code) + if entity_type not in adapter.supported_entity_types(): + raise ValueError( + f"Adapter '{source_code}' does not support entity type '{entity_type}'" + ) + + source, _ = SourceSystem.objects.get_or_create( + code=adapter.source_code, + defaults={"name": adapter.source_name}, + ) + results = [] + for external in adapter.iter_records(entity_type=entity_type): + results.append( + ingest_source_record( + source=source, + entity_type=external.entity_type, + external_id=external.external_id, + payload=external.payload, + source_updated_at=external.source_updated_at, + ) + ) + return results diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py new file mode 100644 index 00000000..bfe6023d --- /dev/null +++ b/backend/commerce/migrations/0001_initial.py @@ -0,0 +1,794 @@ +# Generated for the initial product-centric commerce analytics schema. + +import uuid +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.text +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SourceSystem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.SlugField(max_length=64, unique=True, verbose_name="Код"), + ), + ("name", models.CharField(max_length=128, verbose_name="Название")), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Настройки без секретов" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ], + options={ + "verbose_name": "Источник данных", + "verbose_name_plural": "Источники данных", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "kind", + models.CharField( + choices=[ + ("product", "Товар"), + ("service", "Услуга"), + ("bundle", "Комплект"), + ], + default="product", + max_length=20, + verbose_name="Тип", + ), + ), + ( + "sku", + models.CharField( + max_length=128, unique=True, verbose_name="Внутренний артикул" + ), + ), + ("name", models.CharField(max_length=255, verbose_name="Название")), + ( + "barcode", + models.CharField( + blank=True, + db_index=True, + max_length=64, + verbose_name="Штрихкод", + ), + ), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="variants", + to="commerce.product", + verbose_name="Родительский товар", + ), + ), + ], + options={ + "verbose_name": "Товар", + "verbose_name_plural": "Товары", + "ordering": ["name", "sku"], + }, + ), + migrations.CreateModel( + name="SourceRecord", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "entity_type", + models.CharField(max_length=64, verbose_name="Тип объекта"), + ), + ( + "external_id", + models.CharField(max_length=255, verbose_name="Внешний ID"), + ), + ( + "payload", + models.JSONField(default=dict, verbose_name="Исходные данные"), + ), + ( + "payload_hash", + models.CharField( + editable=False, max_length=64, verbose_name="Хеш данных" + ), + ), + ( + "source_updated_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Изменено в источнике" + ), + ), + ( + "received_at", + models.DateTimeField(auto_now=True, verbose_name="Получено"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="records", + to="commerce.sourcesystem", + verbose_name="Источник", + ), + ), + ], + options={ + "verbose_name": "Исходная запись", + "verbose_name_plural": "Исходные записи", + }, + ), + migrations.CreateModel( + name="ProductBinding", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "external_id", + models.CharField(max_length=255, verbose_name="Внешний ID"), + ), + ( + "external_sku", + models.CharField( + blank=True, + db_index=True, + max_length=128, + verbose_name="Артикул в источнике", + ), + ), + ( + "is_primary", + models.BooleanField( + default=False, verbose_name="Основная привязка" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bindings", + to="commerce.product", + verbose_name="Товар", + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="product_bindings", + to="commerce.sourcesystem", + verbose_name="Источник", + ), + ), + ], + options={ + "verbose_name": "Привязка товара", + "verbose_name_plural": "Привязки товаров", + }, + ), + migrations.CreateModel( + name="Sale", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "external_id", + models.CharField(max_length=255, verbose_name="Внешний ID"), + ), + ( + "event_type", + models.CharField( + choices=[("sale", "Продажа"), ("return", "Возврат")], + default="sale", + max_length=16, + verbose_name="Тип операции", + ), + ), + ( + "number", + models.CharField(blank=True, max_length=128, verbose_name="Номер"), + ), + ( + "channel", + models.CharField( + blank=True, + db_index=True, + max_length=64, + verbose_name="Канал продаж", + ), + ), + ( + "status", + models.CharField( + blank=True, max_length=128, verbose_name="Статус источника" + ), + ), + ( + "counts_as_sale", + models.BooleanField( + default=True, + help_text="Отключается для отменённых или тестовых заказов.", + verbose_name="Учитывать в продажах", + ), + ), + ( + "currency", + models.CharField( + default="RUB", max_length=3, verbose_name="Валюта" + ), + ), + ( + "occurred_at", + models.DateTimeField(db_index=True, verbose_name="Дата операции"), + ), + ( + "source_updated_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Изменено в источнике" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="sales", + to="commerce.sourcesystem", + verbose_name="Источник", + ), + ), + ( + "source_record", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales", + to="commerce.sourcerecord", + verbose_name="Исходная запись", + ), + ), + ], + options={ + "verbose_name": "Продажа", + "verbose_name_plural": "Продажи", + "ordering": ["-occurred_at", "-created_at"], + }, + ), + migrations.CreateModel( + name="SaleLine", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "external_id", + models.CharField(max_length=255, verbose_name="Внешний ID позиции"), + ), + ( + "external_product_id", + models.CharField( + blank=True, max_length=255, verbose_name="Внешний ID товара" + ), + ), + ( + "external_sku", + models.CharField( + blank=True, max_length=128, verbose_name="Внешний артикул" + ), + ), + ( + "name", + models.CharField( + blank=True, max_length=255, verbose_name="Название в источнике" + ), + ), + ( + "quantity", + models.DecimalField( + decimal_places=4, + default=Decimal("1"), + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Количество", + ), + ), + ( + "gross_amount", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Сумма до скидки", + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Скидка", + ), + ), + ( + "cost_amount", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=18, + validators=[ + django.core.validators.MinValueValidator(Decimal("0")) + ], + verbose_name="Себестоимость", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Дополнительные данные" + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sale_lines", + to="commerce.product", + verbose_name="Товар", + ), + ), + ( + "sale", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lines", + to="commerce.sale", + verbose_name="Продажа", + ), + ), + ], + options={ + "verbose_name": "Позиция продажи", + "verbose_name_plural": "Позиции продаж", + }, + ), + migrations.CreateModel( + name="Cost", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "external_id", + models.CharField( + blank=True, max_length=255, verbose_name="Внешний ID" + ), + ), + ( + "channel", + models.CharField( + blank=True, + db_index=True, + max_length=64, + verbose_name="Канал продаж", + ), + ), + ( + "cost_type", + models.CharField( + choices=[ + ("commission", "Комиссия"), + ("delivery", "Доставка"), + ("storage", "Хранение"), + ("advertising", "Реклама"), + ("acquiring", "Эквайринг"), + ("return", "Возврат"), + ("other", "Прочее"), + ], + max_length=32, + verbose_name="Тип расхода", + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + default=Decimal("0"), + max_digits=18, + verbose_name="Сумма", + ), + ), + ( + "currency", + models.CharField( + default="RUB", max_length=3, verbose_name="Валюта" + ), + ), + ( + "occurred_at", + models.DateTimeField(db_index=True, verbose_name="Дата расхода"), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Дополнительные данные" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "product", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="costs", + to="commerce.product", + verbose_name="Товар", + ), + ), + ( + "sale_line", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="costs", + to="commerce.saleline", + verbose_name="Позиция продажи", + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="costs", + to="commerce.sourcesystem", + verbose_name="Источник", + ), + ), + ], + options={"verbose_name": "Расход", "verbose_name_plural": "Расходы"}, + ), + migrations.CreateModel( + name="StockSnapshot", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "warehouse_external_id", + models.CharField(max_length=255, verbose_name="ID склада"), + ), + ( + "warehouse_name", + models.CharField(blank=True, max_length=255, verbose_name="Склад"), + ), + ( + "quantity", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=18, + verbose_name="Остаток", + ), + ), + ( + "reserved", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=18, + verbose_name="Резерв", + ), + ), + ( + "in_transit", + models.DecimalField( + decimal_places=4, + default=Decimal("0"), + max_digits=18, + verbose_name="В пути", + ), + ), + ( + "captured_at", + models.DateTimeField(db_index=True, verbose_name="Дата снимка"), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Дополнительные данные" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stock_snapshots", + to="commerce.product", + verbose_name="Товар", + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="stock_snapshots", + to="commerce.sourcesystem", + verbose_name="Источник", + ), + ), + ], + options={ + "verbose_name": "Снимок остатка", + "verbose_name_plural": "Снимки остатков", + "ordering": ["-captured_at"], + }, + ), + migrations.AddIndex( + model_name="product", + index=models.Index( + fields=["is_active", "name"], name="commerce_product_active_name" + ), + ), + migrations.AddConstraint( + model_name="product", + constraint=models.CheckConstraint( + condition=models.Q(("parent", models.F("id")), _negated=True), + name="commerce_product_not_own_parent", + ), + ), + migrations.AddConstraint( + model_name="product", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("sku"), + name="commerce_product_sku_case_insensitive", + ), + ), + migrations.AddIndex( + model_name="sourcerecord", + index=models.Index( + fields=["source", "entity_type", "source_updated_at"], + name="commerce_source_record_sync", + ), + ), + migrations.AddConstraint( + model_name="sourcerecord", + constraint=models.UniqueConstraint( + fields=("source", "entity_type", "external_id"), + name="commerce_source_record_identity", + ), + ), + migrations.AddIndex( + model_name="productbinding", + index=models.Index( + fields=["source", "external_sku"], + name="commerce_binding_source_sku", + ), + ), + migrations.AddConstraint( + model_name="productbinding", + constraint=models.UniqueConstraint( + fields=("source", "external_id"), + name="commerce_binding_source_external", + ), + ), + migrations.AddIndex( + model_name="sale", + index=models.Index( + fields=["counts_as_sale", "occurred_at"], + name="commerce_sale_counted_date", + ), + ), + migrations.AddConstraint( + model_name="sale", + constraint=models.UniqueConstraint( + fields=("source", "external_id"), + name="commerce_sale_source_external", + ), + ), + migrations.AddIndex( + model_name="saleline", + index=models.Index( + fields=["product", "sale"], name="commerce_line_product_sale" + ), + ), + migrations.AddConstraint( + model_name="saleline", + constraint=models.UniqueConstraint( + fields=("sale", "external_id"), + name="commerce_sale_line_identity", + ), + ), + migrations.AddConstraint( + model_name="saleline", + constraint=models.CheckConstraint( + condition=models.Q(("discount_amount__lte", models.F("gross_amount"))), + name="commerce_sale_line_discount_lte_gross", + ), + ), + migrations.AddIndex( + model_name="cost", + index=models.Index( + fields=["product", "occurred_at"], name="commerce_cost_product_date" + ), + ), + migrations.AddConstraint( + model_name="cost", + constraint=models.CheckConstraint( + condition=models.Q( + ("product__isnull", False), + ("sale_line__isnull", False), + _connector="OR", + ), + name="commerce_cost_has_target", + ), + ), + migrations.AddConstraint( + model_name="cost", + constraint=models.UniqueConstraint( + condition=models.Q(("external_id", ""), _negated=True), + fields=("source", "external_id", "cost_type"), + name="commerce_cost_source_external_type", + ), + ), + migrations.AddIndex( + model_name="stocksnapshot", + index=models.Index( + fields=[ + "product", + "source", + "warehouse_external_id", + "-captured_at", + ], + name="commerce_stock_latest", + ), + ), + migrations.AddConstraint( + model_name="stocksnapshot", + constraint=models.UniqueConstraint( + fields=( + "product", + "source", + "warehouse_external_id", + "captured_at", + ), + name="commerce_stock_snapshot_identity", + ), + ), + ] diff --git a/backend/commerce/migrations/__init__.py b/backend/commerce/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/commerce/models/__init__.py b/backend/commerce/models/__init__.py new file mode 100644 index 00000000..2c77fab4 --- /dev/null +++ b/backend/commerce/models/__init__.py @@ -0,0 +1,19 @@ +from .costs import Cost, CostType +from .product import Product, ProductKind +from .sales import Sale, SaleEventType, SaleLine +from .source import ProductBinding, SourceRecord, SourceSystem +from .stock import StockSnapshot + +__all__ = [ + "Cost", + "CostType", + "Product", + "ProductBinding", + "ProductKind", + "Sale", + "SaleEventType", + "SaleLine", + "SourceRecord", + "SourceSystem", + "StockSnapshot", +] diff --git a/backend/commerce/models/costs.py b/backend/commerce/models/costs.py new file mode 100644 index 00000000..c3a5a0b2 --- /dev/null +++ b/backend/commerce/models/costs.py @@ -0,0 +1,76 @@ +import uuid +from decimal import Decimal + +from django.db import models +from django.db.models import Q + + +class CostType(models.TextChoices): + COMMISSION = "commission", "Комиссия" + DELIVERY = "delivery", "Доставка" + STORAGE = "storage", "Хранение" + ADVERTISING = "advertising", "Реклама" + ACQUIRING = "acquiring", "Эквайринг" + RETURN = "return", "Возврат" + OTHER = "other", "Прочее" + + +class Cost(models.Model): + """Additional product- or sale-line-level expense.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + source = models.ForeignKey( + "commerce.SourceSystem", + on_delete=models.PROTECT, + related_name="costs", + verbose_name="Источник", + ) + product = models.ForeignKey( + "commerce.Product", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="costs", + verbose_name="Товар", + ) + sale_line = models.ForeignKey( + "commerce.SaleLine", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="costs", + verbose_name="Позиция продажи", + ) + external_id = models.CharField("Внешний ID", max_length=255, blank=True) + channel = models.CharField("Канал продаж", max_length=64, blank=True, db_index=True) + cost_type = models.CharField("Тип расхода", max_length=32, choices=CostType.choices) + amount = models.DecimalField( + "Сумма", max_digits=18, decimal_places=2, default=Decimal("0") + ) + currency = models.CharField("Валюта", max_length=3, default="RUB") + occurred_at = models.DateTimeField("Дата расхода", db_index=True) + metadata = models.JSONField("Дополнительные данные", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Расход" + verbose_name_plural = "Расходы" + constraints = [ + models.CheckConstraint( + condition=Q(product__isnull=False) | Q(sale_line__isnull=False), + name="commerce_cost_has_target", + ), + models.UniqueConstraint( + fields=["source", "external_id", "cost_type"], + condition=~Q(external_id=""), + name="commerce_cost_source_external_type", + ), + ] + indexes = [ + models.Index( + fields=["product", "occurred_at"], name="commerce_cost_product_date" + ) + ] + + def __str__(self): + return f"{self.get_cost_type_display()}: {self.amount} {self.currency}" diff --git a/backend/commerce/models/product.py b/backend/commerce/models/product.py new file mode 100644 index 00000000..a48a4865 --- /dev/null +++ b/backend/commerce/models/product.py @@ -0,0 +1,60 @@ +import uuid + +from django.db import models +from django.db.models import Q +from django.db.models.functions import Lower + + +class ProductKind(models.TextChoices): + PRODUCT = "product", "Товар" + SERVICE = "service", "Услуга" + BUNDLE = "bundle", "Комплект" + + +class Product(models.Model): + """Canonical sellable or stockable SKU used by every data source.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + parent = models.ForeignKey( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="variants", + verbose_name="Родительский товар", + ) + kind = models.CharField( + "Тип", + max_length=20, + choices=ProductKind.choices, + default=ProductKind.PRODUCT, + ) + sku = models.CharField("Внутренний артикул", max_length=128, unique=True) + name = models.CharField("Название", max_length=255) + barcode = models.CharField("Штрихкод", max_length=64, blank=True, db_index=True) + description = models.TextField("Описание", blank=True) + is_active = models.BooleanField("Активен", default=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Товар" + verbose_name_plural = "Товары" + ordering = ["name", "sku"] + constraints = [ + models.CheckConstraint( + condition=~Q(parent=models.F("id")), + name="commerce_product_not_own_parent", + ), + models.UniqueConstraint( + Lower("sku"), name="commerce_product_sku_case_insensitive" + ), + ] + indexes = [ + models.Index( + fields=["is_active", "name"], name="commerce_product_active_name" + ), + ] + + def __str__(self): + return f"{self.sku} — {self.name}" diff --git a/backend/commerce/models/sales.py b/backend/commerce/models/sales.py new file mode 100644 index 00000000..7b93d707 --- /dev/null +++ b/backend/commerce/models/sales.py @@ -0,0 +1,151 @@ +import uuid +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models + + +class SaleEventType(models.TextChoices): + SALE = "sale", "Продажа" + RETURN = "return", "Возврат" + + +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) + source = models.ForeignKey( + "commerce.SourceSystem", + on_delete=models.PROTECT, + related_name="sales", + verbose_name="Источник", + ) + source_record = models.ForeignKey( + "commerce.SourceRecord", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sales", + verbose_name="Исходная запись", + ) + external_id = models.CharField("Внешний ID", max_length=255) + event_type = models.CharField( + "Тип операции", + max_length=16, + choices=SaleEventType.choices, + default=SaleEventType.SALE, + ) + number = models.CharField("Номер", max_length=128, blank=True) + channel = models.CharField("Канал продаж", max_length=64, blank=True, db_index=True) + status = models.CharField("Статус источника", max_length=128, blank=True) + counts_as_sale = models.BooleanField( + "Учитывать в продажах", + default=True, + help_text="Отключается для отменённых или тестовых заказов.", + ) + currency = models.CharField("Валюта", max_length=3, default="RUB") + occurred_at = models.DateTimeField("Дата операции", db_index=True) + source_updated_at = models.DateTimeField( + "Изменено в источнике", null=True, blank=True + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Продажа" + verbose_name_plural = "Продажи" + ordering = ["-occurred_at", "-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["source", "external_id"], + name="commerce_sale_source_external", + ) + ] + indexes = [ + models.Index( + fields=["counts_as_sale", "occurred_at"], + name="commerce_sale_counted_date", + ) + ] + + def __str__(self): + return self.number or f"{self.source.code}:{self.external_id}" + + +class SaleLine(models.Model): + """Product-level monetary fact within a sale.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + sale = models.ForeignKey( + Sale, + on_delete=models.CASCADE, + related_name="lines", + verbose_name="Продажа", + ) + product = models.ForeignKey( + "commerce.Product", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="sale_lines", + verbose_name="Товар", + ) + external_id = models.CharField("Внешний ID позиции", max_length=255) + external_product_id = models.CharField( + "Внешний ID товара", max_length=255, blank=True + ) + external_sku = models.CharField("Внешний артикул", max_length=128, blank=True) + name = models.CharField("Название в источнике", max_length=255, blank=True) + quantity = models.DecimalField( + "Количество", + max_digits=18, + decimal_places=4, + default=Decimal("1"), + validators=[MinValueValidator(Decimal("0"))], + ) + gross_amount = models.DecimalField( + "Сумма до скидки", + max_digits=18, + decimal_places=2, + default=Decimal("0"), + validators=[MinValueValidator(Decimal("0"))], + ) + discount_amount = models.DecimalField( + "Скидка", + max_digits=18, + decimal_places=2, + default=Decimal("0"), + validators=[MinValueValidator(Decimal("0"))], + ) + cost_amount = models.DecimalField( + "Себестоимость", + max_digits=18, + decimal_places=2, + default=Decimal("0"), + validators=[MinValueValidator(Decimal("0"))], + ) + metadata = models.JSONField("Дополнительные данные", default=dict, blank=True) + + class Meta: + verbose_name = "Позиция продажи" + verbose_name_plural = "Позиции продаж" + constraints = [ + models.UniqueConstraint( + fields=["sale", "external_id"], + name="commerce_sale_line_identity", + ), + models.CheckConstraint( + condition=models.Q(discount_amount__lte=models.F("gross_amount")), + name="commerce_sale_line_discount_lte_gross", + ), + ] + indexes = [ + models.Index(fields=["product", "sale"], name="commerce_line_product_sale") + ] + + @property + def net_amount(self): + return self.gross_amount - self.discount_amount + + def __str__(self): + return self.name or self.external_sku or self.external_id diff --git a/backend/commerce/models/source.py b/backend/commerce/models/source.py new file mode 100644 index 00000000..ce8593f1 --- /dev/null +++ b/backend/commerce/models/source.py @@ -0,0 +1,106 @@ +import uuid + +from django.db import models + + +class SourceSystem(models.Model): + """External system that supplies observations and business events.""" + + code = models.SlugField("Код", max_length=64, unique=True) + name = models.CharField("Название", max_length=128) + is_active = models.BooleanField("Активен", default=True) + metadata = models.JSONField("Настройки без секретов", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Источник данных" + verbose_name_plural = "Источники данных" + ordering = ["name"] + + def __str__(self): + return self.name + + +class SourceRecord(models.Model): + """Latest source payload retained with provenance for repeatable imports.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + source = models.ForeignKey( + SourceSystem, + on_delete=models.PROTECT, + related_name="records", + verbose_name="Источник", + ) + entity_type = models.CharField("Тип объекта", max_length=64) + external_id = models.CharField("Внешний ID", max_length=255) + payload = models.JSONField("Исходные данные", default=dict) + payload_hash = models.CharField("Хеш данных", max_length=64, editable=False) + source_updated_at = models.DateTimeField( + "Изменено в источнике", null=True, blank=True + ) + received_at = models.DateTimeField("Получено", auto_now=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Исходная запись" + verbose_name_plural = "Исходные записи" + constraints = [ + models.UniqueConstraint( + fields=["source", "entity_type", "external_id"], + name="commerce_source_record_identity", + ) + ] + indexes = [ + models.Index( + fields=["source", "entity_type", "source_updated_at"], + name="commerce_source_record_sync", + ) + ] + + def __str__(self): + return f"{self.source.code}:{self.entity_type}:{self.external_id}" + + +class ProductBinding(models.Model): + """Maps an external product/offer identifier to the canonical product.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + product = models.ForeignKey( + "commerce.Product", + on_delete=models.CASCADE, + related_name="bindings", + verbose_name="Товар", + ) + source = models.ForeignKey( + SourceSystem, + on_delete=models.PROTECT, + related_name="product_bindings", + verbose_name="Источник", + ) + external_id = models.CharField("Внешний ID", max_length=255) + external_sku = models.CharField( + "Артикул в источнике", max_length=128, blank=True, db_index=True + ) + is_primary = models.BooleanField("Основная привязка", default=False) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Привязка товара" + verbose_name_plural = "Привязки товаров" + constraints = [ + models.UniqueConstraint( + fields=["source", "external_id"], + name="commerce_binding_source_external", + ) + ] + indexes = [ + models.Index( + fields=["source", "external_sku"], + name="commerce_binding_source_sku", + ) + ] + + def __str__(self): + return f"{self.product.sku} ← {self.source.code}:{self.external_id}" diff --git a/backend/commerce/models/stock.py b/backend/commerce/models/stock.py new file mode 100644 index 00000000..b647d69b --- /dev/null +++ b/backend/commerce/models/stock.py @@ -0,0 +1,61 @@ +import uuid +from decimal import Decimal + +from django.db import models + + +class StockSnapshot(models.Model): + """Point-in-time stock observation supplied by a source system.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + product = models.ForeignKey( + "commerce.Product", + on_delete=models.CASCADE, + related_name="stock_snapshots", + verbose_name="Товар", + ) + source = models.ForeignKey( + "commerce.SourceSystem", + on_delete=models.PROTECT, + related_name="stock_snapshots", + verbose_name="Источник", + ) + warehouse_external_id = models.CharField("ID склада", max_length=255) + warehouse_name = models.CharField("Склад", max_length=255, blank=True) + quantity = models.DecimalField( + "Остаток", max_digits=18, decimal_places=4, default=Decimal("0") + ) + reserved = models.DecimalField( + "Резерв", max_digits=18, decimal_places=4, default=Decimal("0") + ) + in_transit = models.DecimalField( + "В пути", max_digits=18, decimal_places=4, default=Decimal("0") + ) + captured_at = models.DateTimeField("Дата снимка", db_index=True) + metadata = models.JSONField("Дополнительные данные", default=dict, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + + class Meta: + verbose_name = "Снимок остатка" + verbose_name_plural = "Снимки остатков" + ordering = ["-captured_at"] + constraints = [ + models.UniqueConstraint( + fields=["product", "source", "warehouse_external_id", "captured_at"], + name="commerce_stock_snapshot_identity", + ) + ] + indexes = [ + models.Index( + fields=["product", "source", "warehouse_external_id", "-captured_at"], + name="commerce_stock_latest", + ) + ] + + @property + def available(self): + return self.quantity - self.reserved + + def __str__(self): + warehouse = self.warehouse_name or self.warehouse_external_id + return f"{self.product.sku} · {warehouse} · {self.quantity}" diff --git a/backend/commerce/services/__init__.py b/backend/commerce/services/__init__.py new file mode 100644 index 00000000..46ddc679 --- /dev/null +++ b/backend/commerce/services/__init__.py @@ -0,0 +1,13 @@ +"""Application services for normalized commerce data.""" + +from .ingestion import IngestResult, ingest_source_record +from .matching import ProductMatch, match_product +from .metrics import get_product_metrics + +__all__ = [ + "IngestResult", + "ProductMatch", + "get_product_metrics", + "ingest_source_record", + "match_product", +] diff --git a/backend/commerce/services/ingestion.py b/backend/commerce/services/ingestion.py new file mode 100644 index 00000000..4b99989d --- /dev/null +++ b/backend/commerce/services/ingestion.py @@ -0,0 +1,80 @@ +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from django.db import transaction + +from commerce.models import SourceRecord, SourceSystem + + +@dataclass(frozen=True) +class IngestResult: + record: SourceRecord + created: bool + changed: bool + + +def payload_hash(payload: dict[str, Any]) -> str: + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +@transaction.atomic +def ingest_source_record( + *, + source: SourceSystem, + entity_type: str, + external_id: str, + payload: dict[str, Any], + source_updated_at: datetime | None = None, +) -> IngestResult: + """Idempotently retain the latest payload and report whether it changed.""" + + digest = payload_hash(payload) + existing = ( + SourceRecord.objects.select_for_update() + .filter( + source=source, + entity_type=entity_type, + external_id=str(external_id), + ) + .first() + ) + if existing is not None: + changed = ( + existing.payload_hash != digest + or existing.source_updated_at != source_updated_at + ) + if changed: + existing.payload = payload + existing.payload_hash = digest + existing.source_updated_at = source_updated_at + existing.save( + update_fields=[ + "payload", + "payload_hash", + "source_updated_at", + "received_at", + ] + ) + else: + existing.save(update_fields=["received_at"]) + return IngestResult(record=existing, created=False, changed=changed) + + record = SourceRecord.objects.create( + source=source, + entity_type=entity_type, + external_id=str(external_id), + payload=payload, + payload_hash=digest, + source_updated_at=source_updated_at, + ) + return IngestResult(record=record, created=True, changed=True) diff --git a/backend/commerce/services/matching.py b/backend/commerce/services/matching.py new file mode 100644 index 00000000..c7878a59 --- /dev/null +++ b/backend/commerce/services/matching.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass + +from commerce.models import Product, ProductBinding, SourceSystem + + +@dataclass(frozen=True) +class ProductMatch: + product: Product | None + method: str | None + + @property + def matched(self) -> bool: + return self.product is not None + + +def _normalized(value: str | None) -> str: + return (value or "").strip() + + +def match_product( + *, + source: SourceSystem, + external_id: str | None = None, + external_sku: str | None = None, + barcode: str | None = None, +) -> ProductMatch: + """Resolve a source product using explicit bindings before safe identifiers.""" + + external_id = _normalized(external_id) + external_sku = _normalized(external_sku) + barcode = _normalized(barcode) + + if external_id: + binding = ( + ProductBinding.objects.select_related("product") + .filter(source=source, external_id=external_id) + .first() + ) + if binding: + return ProductMatch(binding.product, "external_id") + + if external_sku: + bindings = list( + ProductBinding.objects.select_related("product").filter( + source=source, external_sku__iexact=external_sku + )[:2] + ) + if len(bindings) == 1: + return ProductMatch(bindings[0].product, "external_sku") + + products = list(Product.objects.filter(sku__iexact=external_sku)[:2]) + if len(products) == 1: + return ProductMatch(products[0], "canonical_sku") + + if barcode: + products = list(Product.objects.filter(barcode=barcode)[:2]) + if len(products) == 1: + return ProductMatch(products[0], "barcode") + + return ProductMatch(None, None) diff --git a/backend/commerce/services/metrics.py b/backend/commerce/services/metrics.py new file mode 100644 index 00000000..1a97b0ec --- /dev/null +++ b/backend/commerce/services/metrics.py @@ -0,0 +1,238 @@ +from collections import defaultdict +from datetime import datetime +from decimal import Decimal + +from django.db.models import ( + Case, + CharField, + DecimalField, + ExpressionWrapper, + F, + OuterRef, + Q, + Subquery, + Sum, + Value, + When, +) +from django.db.models.functions import Coalesce + +from commerce.models import Cost, Product, SaleEventType, SaleLine, StockSnapshot + +MONEY_FIELD = DecimalField(max_digits=20, decimal_places=2) +ZERO_MONEY = Decimal("0.00") + + +def _date_filter(prefix: str, date_from: datetime | None, date_to: datetime | None): + query = Q() + if date_from is not None: + query &= Q(**{f"{prefix}__gte": date_from}) + if date_to is not None: + query &= Q(**{f"{prefix}__lte": date_to}) + return query + + +def _sale_metric_rows( + product: Product, + date_from: datetime | None, + date_to: datetime | None, + *group_fields: str, +): + signed_quantity = ExpressionWrapper( + F("quantity") * models_case_return_multiplier(), + output_field=DecimalField(max_digits=20, decimal_places=4), + ) + signed_gross = ExpressionWrapper( + F("gross_amount") * models_case_return_multiplier(), output_field=MONEY_FIELD + ) + signed_discount = ExpressionWrapper( + F("discount_amount") * models_case_return_multiplier(), + output_field=MONEY_FIELD, + ) + signed_cost = ExpressionWrapper( + F("cost_amount") * models_case_return_multiplier(), output_field=MONEY_FIELD + ) + + rows = ( + SaleLine.objects.filter(product=product, sale__counts_as_sale=True) + .filter(_date_filter("sale__occurred_at", date_from, date_to)) + .values(*group_fields) + .annotate( + quantity=Coalesce(Sum(signed_quantity), Decimal("0")), + gross=Coalesce(Sum(signed_gross), ZERO_MONEY), + discounts=Coalesce(Sum(signed_discount), ZERO_MONEY), + cost_of_goods=Coalesce(Sum(signed_cost), ZERO_MONEY), + ) + .order_by(*group_fields) + ) + return list(rows) + + +def _sale_metrics( + product: Product, + date_from: datetime | None, + date_to: datetime | None, +): + rows = _sale_metric_rows(product, date_from, date_to, "sale__currency") + return {row["sale__currency"]: row for row in rows} + + +def models_case_return_multiplier(): + from django.db.models import Case, IntegerField, Value, When + + return Case( + When(sale__event_type=SaleEventType.RETURN, then=Value(-1)), + default=Value(1), + output_field=IntegerField(), + ) + + +def _cost_metrics( + product: Product, + date_from: datetime | None, + date_to: datetime | None, +): + rows = ( + Cost.objects.filter(Q(product=product) | Q(sale_line__product=product)) + .filter(_date_filter("occurred_at", date_from, date_to)) + .values("currency", "cost_type") + .annotate(amount=Coalesce(Sum("amount"), ZERO_MONEY)) + .order_by("currency", "cost_type") + ) + result: dict[str, dict[str, Decimal]] = defaultdict(dict) + for row in rows: + result[row["currency"]][row["cost_type"]] = row["amount"] + return result + + +def _channel_metrics( + product: Product, + date_from: datetime | None, + date_to: datetime | None, +): + sale_rows = _sale_metric_rows( + product, + date_from, + date_to, + "sale__channel", + "sale__currency", + ) + sales = {(row["sale__channel"], row["sale__currency"]): row for row in sale_rows} + + cost_rows = ( + Cost.objects.filter(Q(product=product) | Q(sale_line__product=product)) + .filter(_date_filter("occurred_at", date_from, date_to)) + .annotate( + effective_channel=Case( + When( + channel="", + then=Coalesce("sale_line__sale__channel", Value("")), + ), + default=F("channel"), + output_field=CharField(), + ) + ) + .values("effective_channel", "currency", "cost_type") + .annotate(amount=Coalesce(Sum("amount"), ZERO_MONEY)) + .order_by("effective_channel", "currency", "cost_type") + ) + costs: dict[tuple[str, str], dict[str, Decimal]] = defaultdict(dict) + for row in cost_rows: + costs[(row["effective_channel"], row["currency"])][row["cost_type"]] = row[ + "amount" + ] + + result = [] + for channel, currency in sorted(set(sales) | set(costs)): + sale = sales.get((channel, currency), {}) + gross = sale.get("gross", ZERO_MONEY) + discounts = sale.get("discounts", ZERO_MONEY) + cost_of_goods = sale.get("cost_of_goods", ZERO_MONEY) + expense_breakdown = costs.get((channel, currency), {}) + additional_costs = sum(expense_breakdown.values(), ZERO_MONEY) + net_revenue = gross - discounts + result.append( + { + "channel": channel or "unattributed", + "currency": currency, + "quantity": sale.get("quantity", Decimal("0")), + "net_revenue": net_revenue, + "cost_of_goods": cost_of_goods, + "additional_costs": additional_costs, + "contribution_margin": net_revenue - cost_of_goods - additional_costs, + "cost_breakdown": expense_breakdown, + } + ) + return result + + +def _latest_stock(product: Product): + latest_pk = ( + StockSnapshot.objects.filter( + product=product, + source_id=OuterRef("source_id"), + warehouse_external_id=OuterRef("warehouse_external_id"), + ) + .order_by("-captured_at", "-created_at") + .values("pk")[:1] + ) + snapshots = ( + StockSnapshot.objects.filter(product=product, pk=Subquery(latest_pk)) + .select_related("source") + .order_by("source__code", "warehouse_external_id") + ) + + return [ + { + "source": snapshot.source.code, + "warehouse_external_id": snapshot.warehouse_external_id, + "warehouse_name": snapshot.warehouse_name, + "quantity": snapshot.quantity, + "reserved": snapshot.reserved, + "available": snapshot.available, + "in_transit": snapshot.in_transit, + "captured_at": snapshot.captured_at, + } + for snapshot in snapshots + ] + + +def get_product_metrics( + product: Product, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, +): + sales = _sale_metrics(product, date_from, date_to) + costs = _cost_metrics(product, date_from, date_to) + currencies = sorted(set(sales) | set(costs)) + monetary = [] + for currency in currencies: + sale = sales.get(currency, {}) + gross = sale.get("gross", ZERO_MONEY) + discounts = sale.get("discounts", ZERO_MONEY) + cost_of_goods = sale.get("cost_of_goods", ZERO_MONEY) + expense_breakdown = costs.get(currency, {}) + additional_costs = sum(expense_breakdown.values(), ZERO_MONEY) + net_revenue = gross - discounts + monetary.append( + { + "currency": currency, + "quantity": sale.get("quantity", Decimal("0")), + "gross_revenue": gross, + "discounts": discounts, + "net_revenue": net_revenue, + "cost_of_goods": cost_of_goods, + "additional_costs": additional_costs, + "contribution_margin": net_revenue - cost_of_goods - additional_costs, + "cost_breakdown": expense_breakdown, + } + ) + + return { + "product_id": product.id, + "period": {"from": date_from, "to": date_to}, + "monetary": monetary, + "by_channel": _channel_metrics(product, date_from, date_to), + "stock": _latest_stock(product), + } diff --git a/backend/commerce/tasks/__init__.py b/backend/commerce/tasks/__init__.py new file mode 100644 index 00000000..b64ecc93 --- /dev/null +++ b/backend/commerce/tasks/__init__.py @@ -0,0 +1,13 @@ +from celery import shared_task + +from commerce.integrations import sync_registered_source + + +@shared_task(name="commerce.sync_source") +def sync_source(source_code: str, entity_type: str): + results = sync_registered_source(source_code, entity_type=entity_type) + return { + "processed": len(results), + "created": sum(result.created for result in results), + "changed": sum(result.changed for result in results), + } diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 3b4669af..9445302e 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -75,6 +75,7 @@ INSTALLED_APPS = [ "search.apps.SearchConfig", "finance.apps.FinanceConfig", "procurement.apps.ProcurementConfig", + "commerce.apps.CommerceConfig", "tasks.apps.TasksConfig", "guests.apps.GuestsConfig", "push_notifications", # django-push-notifications для Web Push diff --git a/backend/tests/commerce/__init__.py b/backend/tests/commerce/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/commerce/test_api.py b/backend/tests/commerce/test_api.py new file mode 100644 index 00000000..12d9aa59 --- /dev/null +++ b/backend/tests/commerce/test_api.py @@ -0,0 +1,76 @@ +from decimal import Decimal + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status + +from commerce.models import Product, Sale, SaleLine, SourceSystem + +pytestmark = pytest.mark.django_db + + +def test_commerce_api_requires_authentication(api_client): + response = api_client.get(reverse("api:v1:commerce:product-list")) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_employee_can_read_but_cannot_create_product(api_client, user_factory): + user = user_factory() + Product.objects.create(sku="FISHKA", name="Фишка") + api_client.force_authenticate(user=user) + + list_response = api_client.get(reverse("api:v1:commerce:product-list")) + create_response = api_client.post( + reverse("api:v1:commerce:product-list"), + {"sku": "NEW", "name": "Новый товар", "kind": "product"}, + format="json", + ) + + assert list_response.status_code == status.HTTP_200_OK + assert list_response.data["count"] == 1 + assert create_response.status_code == status.HTTP_403_FORBIDDEN + + +def test_staff_can_create_product(api_client, user_factory): + user = user_factory(staff=True) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:commerce:product-list"), + {"sku": "NEW", "name": "Новый товар", "kind": "product"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert Product.objects.filter(sku="NEW").exists() + + +def test_product_analytics_endpoint(api_client, user_factory): + user = user_factory() + source = SourceSystem.objects.create(code="insales", name="InSales") + product = Product.objects.create(sku="FISHKA", name="Фишка") + sale = Sale.objects.create( + source=source, + external_id="order-1", + occurred_at=timezone.now(), + channel="site", + ) + SaleLine.objects.create( + sale=sale, + product=product, + external_id="line-1", + quantity=Decimal("1"), + gross_amount=Decimal("500"), + cost_amount=Decimal("200"), + ) + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("api:v1:commerce:product-analytics", args=[product.pk]) + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["product_id"] == product.pk + assert response.data["monetary"][0]["net_revenue"] == Decimal("500") + assert response.data["monetary"][0]["contribution_margin"] == Decimal("300") diff --git a/backend/tests/commerce/test_services.py b/backend/tests/commerce/test_services.py new file mode 100644 index 00000000..e1d3bb61 --- /dev/null +++ b/backend/tests/commerce/test_services.py @@ -0,0 +1,180 @@ +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils import timezone + +from commerce.models import ( + Cost, + CostType, + Product, + ProductBinding, + Sale, + SaleEventType, + SaleLine, + SourceSystem, + StockSnapshot, +) +from commerce.services import get_product_metrics, ingest_source_record, match_product + +pytestmark = pytest.mark.django_db + + +def test_ingestion_is_idempotent_and_detects_payload_changes(): + source = SourceSystem.objects.create(code="moysklad", name="МойСклад") + + first = ingest_source_record( + source=source, + entity_type="product", + external_id="product-1", + payload={"name": "Фишка", "stock": 10}, + ) + repeated = ingest_source_record( + source=source, + entity_type="product", + external_id="product-1", + payload={"stock": 10, "name": "Фишка"}, + ) + changed = ingest_source_record( + source=source, + entity_type="product", + external_id="product-1", + payload={"name": "Фишка", "stock": 8}, + ) + + assert first.created is True + assert repeated.created is False + assert repeated.changed is False + assert changed.changed is True + assert changed.record.pk == first.record.pk + assert changed.record.payload["stock"] == 8 + + +def test_product_matching_prefers_binding_and_rejects_ambiguous_sku(): + source = SourceSystem.objects.create(code="retailcrm", name="RetailCRM") + first = Product.objects.create(sku="FISHKA-RED", name="Фишка красная") + second = Product.objects.create(sku="FISHKA-BLUE", name="Фишка синяя") + ProductBinding.objects.create( + product=first, + source=source, + external_id="offer-1", + external_sku="shared-sku", + ) + ProductBinding.objects.create( + product=second, + source=source, + external_id="offer-2", + external_sku="shared-sku", + ) + + explicit = match_product(source=source, external_id="offer-1") + ambiguous = match_product(source=source, external_sku="shared-sku") + canonical = match_product(source=source, external_sku="fishka-blue") + + assert explicit.product == first + assert explicit.method == "external_id" + assert ambiguous.matched is False + assert canonical.product == second + assert canonical.method == "canonical_sku" + + +def test_product_metrics_include_returns_costs_and_latest_stock(): + now = timezone.now() + source = SourceSystem.objects.create(code="moysklad", name="МойСклад") + ads = SourceSystem.objects.create(code="ads", name="Реклама") + product = Product.objects.create(sku="FISHKA", name="Фишка") + + sale = Sale.objects.create( + source=source, + external_id="sale-1", + event_type=SaleEventType.SALE, + channel="site", + occurred_at=now - timedelta(days=2), + ) + sale_line = SaleLine.objects.create( + sale=sale, + product=product, + external_id="line-1", + quantity=Decimal("2"), + gross_amount=Decimal("1000"), + discount_amount=Decimal("100"), + cost_amount=Decimal("400"), + ) + returned = Sale.objects.create( + source=source, + external_id="return-1", + event_type=SaleEventType.RETURN, + channel="site", + occurred_at=now - timedelta(days=1), + ) + SaleLine.objects.create( + sale=returned, + product=product, + external_id="return-line-1", + quantity=Decimal("1"), + gross_amount=Decimal("200"), + cost_amount=Decimal("80"), + ) + Cost.objects.create( + source=source, + product=product, + external_id="commission-1", + cost_type=CostType.COMMISSION, + amount=Decimal("50"), + occurred_at=now, + ) + Cost.objects.create( + source=ads, + sale_line=sale_line, + external_id="ad-1", + cost_type=CostType.ADVERTISING, + amount=Decimal("20"), + occurred_at=now, + ) + + StockSnapshot.objects.create( + product=product, + source=source, + warehouse_external_id="main", + quantity=Decimal("10"), + reserved=Decimal("2"), + captured_at=now - timedelta(hours=2), + ) + StockSnapshot.objects.create( + product=product, + source=source, + warehouse_external_id="main", + quantity=Decimal("7"), + reserved=Decimal("1"), + captured_at=now, + ) + StockSnapshot.objects.create( + product=product, + source=source, + warehouse_external_id="reserve", + quantity=Decimal("3"), + captured_at=now, + ) + + result = get_product_metrics(product) + rub = result["monetary"][0] + + assert rub["currency"] == "RUB" + assert rub["quantity"] == Decimal("1") + assert rub["gross_revenue"] == Decimal("800") + assert rub["discounts"] == Decimal("100") + assert rub["net_revenue"] == Decimal("700") + assert rub["cost_of_goods"] == Decimal("320") + assert rub["additional_costs"] == Decimal("70") + assert rub["contribution_margin"] == Decimal("310") + channels = {row["channel"]: row for row in result["by_channel"]} + assert channels["site"]["net_revenue"] == Decimal("700") + assert channels["site"]["additional_costs"] == Decimal("20") + assert channels["site"]["contribution_margin"] == Decimal("360") + assert channels["unattributed"]["additional_costs"] == Decimal("50") + assert len(result["stock"]) == 2 + main = next( + item for item in result["stock"] if item["warehouse_external_id"] == "main" + ) + assert main["quantity"] == Decimal("7") + assert main["available"] == Decimal("6") diff --git a/frontend/src/app/products/[productId]/page.tsx b/frontend/src/app/products/[productId]/page.tsx new file mode 100644 index 00000000..01ee378e --- /dev/null +++ b/frontend/src/app/products/[productId]/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { AppShell } from "@/components/AppShell"; +import { apiClient } from "@/lib/api"; +import type { CommerceProduct, ProductMetrics } from "@/types/commerce"; +import { ArrowLeft, Boxes, Link2, TrendingUp } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +function formatDecimal(value: string, maximumFractionDigits = 2) { + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits }).format( + Number(value), + ); +} + +function formatMoney(value: string, currency: string) { + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(Number(value)); +} + +export default function ProductDetailPage() { + const params = useParams<{ productId: string }>(); + const [product, setProduct] = useState(null); + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + Promise.all([ + apiClient.getCommerceProduct(params.productId), + apiClient.getCommerceProductMetrics(params.productId), + ]) + .then(([productData, metricsData]) => { + if (!active) return; + setProduct(productData); + setMetrics(metricsData); + }) + .catch((requestError) => { + if (!active) return; + setError( + requestError instanceof Error + ? requestError.message + : "Не удалось загрузить карточку товара.", + ); + }); + return () => { + active = false; + }; + }, [params.productId]); + + return ( + +
+ + К каталогу + + + {error ? ( +
{error}
+ ) : !product || !metrics ? ( +

+ Загрузка карточки… +

+ ) : ( + <> +
+
+
+

+ {product.sku} +

+

+ {product.name} +

+ {product.description ? ( +

+ {product.description} +

+ ) : null} +
+ + {product.is_active ? "Активен" : "Архив"} + +
+
+ +
+ {metrics.monetary.length === 0 ? ( +
+

+ Денежных операций по товару пока нет. +

+
+ ) : ( + metrics.monetary.map((money) => ( +
+
+ +

Продажи · {money.currency}

+
+
+ + + + + +
+
+ )) + )} +
+ +
+
+

Продажи по каналам

+ {metrics.by_channel.length === 0 ? ( +

Разбивки по каналам пока нет.

+ ) : ( +
+ + + + + + + + + + + + + {metrics.by_channel.map((channel) => ( + + + + + + + + + ))} + +
КаналКоличествоВыручкаСебестоимостьРасходыМаржа
{channel.channel}{formatDecimal(channel.quantity, 4)}{formatMoney(channel.net_revenue, channel.currency)}{formatMoney(channel.cost_of_goods, channel.currency)}{formatMoney(channel.additional_costs, channel.currency)}{formatMoney(channel.contribution_margin, channel.currency)}
+
+ )} +
+ +
+
+ +

Остатки по источникам

+
+ {metrics.stock.length === 0 ? ( +

Снимков остатков пока нет.

+ ) : ( +
+ {metrics.stock.map((stock) => ( +
+
+
+

{stock.warehouse_name || stock.warehouse_external_id}

+

{stock.source}

+
+

{formatDecimal(stock.available, 4)} доступно

+
+

+ Всего {formatDecimal(stock.quantity, 4)} · резерв {formatDecimal(stock.reserved, 4)} · в пути {formatDecimal(stock.in_transit, 4)} +

+
+ ))} +
+ )} +
+ +
+
+ +

Внешние соответствия

+
+ {product.bindings.length === 0 ? ( +

Привязки ещё не настроены.

+ ) : ( +
+ {product.bindings.map((binding) => ( +
+

{binding.source_code}

+

+ ID: {binding.external_id} + {binding.external_sku ? ` · SKU: ${binding.external_sku}` : ""} +

+
+ ))} +
+ )} +
+
+ + )} +
+
+ ); +} + +function Metric({ + label, + value, + accent = false, +}: { + label: string; + value: string; + accent?: boolean; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx new file mode 100644 index 00000000..d438ddb8 --- /dev/null +++ b/frontend/src/app/products/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { AppShell } from "@/components/AppShell"; +import { apiClient } from "@/lib/api"; +import type { CommerceProduct } from "@/types/commerce"; +import { ChevronRight, PackageSearch, Search } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +const kindLabels: Record = { + product: "Товар", + service: "Услуга", + bundle: "Комплект", +}; + +export default function ProductsPage() { + const [products, setProducts] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadProducts = useCallback(async (query = "") => { + setLoading(true); + setError(null); + try { + const response = await apiClient.getCommerceProducts({ + search: query || undefined, + ordering: "name", + is_active: true, + }); + setProducts(response.results); + } catch (requestError) { + setError( + requestError instanceof Error + ? requestError.message + : "Не удалось загрузить товары.", + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const timeout = window.setTimeout(() => void loadProducts(search.trim()), 250); + return () => window.clearTimeout(timeout); + }, [loadProducts, search]); + + return ( + +
+
+

+ Коммерческое ядро +

+

+ Единый каталог товаров +

+

+ Внутренние SKU и их соответствия во внешних системах. +

+
+ + + + {error ? ( +
{error}
+ ) : null} + +
+ {loading ? ( +

Загрузка…

+ ) : products.length === 0 ? ( +
+ +

+ Товары пока не добавлены или не найдены. +

+
+ ) : ( +
+ {products.map((product) => ( + +
+
+

+ {product.name} +

+ + {kindLabels[product.kind]} + +
+

+ {product.sku} + {product.bindings.length > 0 + ? ` · ${product.bindings.length} интеграций` + : " · без внешних привязок"} +

+
+ + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index df40da3a..cc3e180e 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,6 @@ "use client"; -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 { 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 Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -66,6 +66,7 @@ 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 }, { 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 }, diff --git a/frontend/src/lib/api/commerce.ts b/frontend/src/lib/api/commerce.ts new file mode 100644 index 00000000..92be369f --- /dev/null +++ b/frontend/src/lib/api/commerce.ts @@ -0,0 +1,36 @@ +import type { + CommerceProduct, + PaginatedCommerceResponse, + ProductMetrics, +} from "@/types/commerce"; + +import { buildQuery, type RequestFn } from "./utils"; + +type ProductListParams = { + search?: string; + kind?: "product" | "service" | "bundle"; + is_active?: boolean; + ordering?: string; + page?: number; +}; + +type ProductMetricsParams = { + date_from?: string; + date_to?: string; +}; + +export function createCommerceApi(request: RequestFn) { + return { + getCommerceProducts: ( + params?: ProductListParams, + ): Promise> => + request(`/api/v1/commerce/products/${buildQuery(params)}`), + getCommerceProduct: (id: string): Promise => + request(`/api/v1/commerce/products/${id}/`), + getCommerceProductMetrics: ( + id: string, + params?: ProductMetricsParams, + ): Promise => + request(`/api/v1/commerce/products/${id}/analytics/${buildQuery(params)}`), + }; +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index faf12016..79548112 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -17,6 +17,7 @@ import { createAttendanceApi } from './attendance'; import { createGuestsApi } from './guests'; import { createTasksApi } from './tasks'; import { createFinanceApi } from './finance'; +import { createCommerceApi } from './commerce'; const base = new ApiClientBase(); const req = base.request.bind(base); @@ -37,6 +38,7 @@ export const apiClient = Object.assign(base, { ...createGuestsApi(req), ...createTasksApi(req, raw), ...createFinanceApi(req), + ...createCommerceApi(req), }); export default apiClient; diff --git a/frontend/src/types/commerce.ts b/frontend/src/types/commerce.ts new file mode 100644 index 00000000..96b11db7 --- /dev/null +++ b/frontend/src/types/commerce.ts @@ -0,0 +1,78 @@ +export type PaginatedCommerceResponse = { + count: number; + next: string | null; + previous: string | null; + results: T[]; +}; + +export type ProductBinding = { + id: string; + product: string; + source: number; + source_code: string; + external_id: string; + external_sku: string; + is_primary: boolean; + created_at: string; + updated_at: string; +}; + +export type CommerceProduct = { + id: string; + parent: string | null; + kind: "product" | "service" | "bundle"; + sku: string; + name: string; + barcode: string; + description: string; + is_active: boolean; + variants_count: number; + bindings: ProductBinding[]; + created_at: string; + updated_at: string; +}; + +export type ProductMonetaryMetrics = { + currency: string; + quantity: string; + gross_revenue: string; + discounts: string; + net_revenue: string; + cost_of_goods: string; + additional_costs: string; + contribution_margin: string; + cost_breakdown: Record; +}; + +export type ProductStockMetric = { + source: string; + warehouse_external_id: string; + warehouse_name: string; + quantity: string; + reserved: string; + available: string; + in_transit: string; + captured_at: string; +}; + +export type ProductChannelMetrics = { + channel: string; + currency: string; + quantity: string; + net_revenue: string; + cost_of_goods: string; + additional_costs: string; + contribution_margin: string; + cost_breakdown: Record; +}; + +export type ProductMetrics = { + product_id: string; + period: { + from: string | null; + to: string | null; + }; + monetary: ProductMonetaryMetrics[]; + by_channel: ProductChannelMetrics[]; + stock: ProductStockMetric[]; +};