Add product-centric commerce analytics core

This commit is contained in:
lizerk 2026-08-17 17:16:42 +03:00
parent 53cf1918c0
commit a041d4844a
36 changed files with 3018 additions and 1 deletions

View File

@ -0,0 +1 @@
"""REST API for product-centric commerce analytics."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Product-centric commerce analytics domain."""

118
backend/commerce/admin.py Normal file
View File

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

7
backend/commerce/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CommerceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "commerce"
verbose_name = "Товарная аналитика"

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@ -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<CommerceProduct | null>(null);
const [metrics, setMetrics] = useState<ProductMetrics | null>(null);
const [error, setError] = useState<string | null>(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 (
<AppShell>
<main className="mx-auto w-full max-w-7xl space-y-5 p-4 sm:p-6">
<Link
href="/products"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-4 w-4" /> К каталогу
</Link>
{error ? (
<div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div>
) : !product || !metrics ? (
<p className="app-surface rounded-xl p-6 text-sm text-[var(--muted-foreground)]">
Загрузка карточки…
</p>
) : (
<>
<header className="app-surface rounded-xl p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
{product.sku}
</p>
<h1 className="mt-1 text-2xl font-semibold text-[var(--foreground)]">
{product.name}
</h1>
{product.description ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--muted-foreground)]">
{product.description}
</p>
) : null}
</div>
<span className="app-badge rounded-full px-3 py-1 text-xs">
{product.is_active ? "Активен" : "Архив"}
</span>
</div>
</header>
<section className="grid gap-4 lg:grid-cols-3">
{metrics.monetary.length === 0 ? (
<div className="app-surface rounded-xl p-5 lg:col-span-3">
<p className="text-sm text-[var(--muted-foreground)]">
Денежных операций по товару пока нет.
</p>
</div>
) : (
metrics.monetary.map((money) => (
<article key={money.currency} className="app-surface rounded-xl p-5 lg:col-span-3">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
<h2 className="font-semibold">Продажи · {money.currency}</h2>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="Количество" value={formatDecimal(money.quantity, 4)} />
<Metric label="Выручка" value={formatMoney(money.net_revenue, money.currency)} />
<Metric label="Себестоимость" value={formatMoney(money.cost_of_goods, money.currency)} />
<Metric label="Доп. расходы" value={formatMoney(money.additional_costs, money.currency)} />
<Metric label="Маржинальный доход" value={formatMoney(money.contribution_margin, money.currency)} accent />
</div>
</article>
))
)}
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="app-surface rounded-xl p-5 lg:col-span-2">
<h2 className="mb-4 font-semibold">Продажи по каналам</h2>
{metrics.by_channel.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Разбивки по каналам пока нет.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
<tr>
<th className="pb-3 font-medium">Канал</th>
<th className="pb-3 text-right font-medium">Количество</th>
<th className="pb-3 text-right font-medium">Выручка</th>
<th className="pb-3 text-right font-medium">Себестоимость</th>
<th className="pb-3 text-right font-medium">Расходы</th>
<th className="pb-3 text-right font-medium">Маржа</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{metrics.by_channel.map((channel) => (
<tr key={`${channel.channel}:${channel.currency}`}>
<td className="py-3 font-medium">{channel.channel}</td>
<td className="py-3 text-right">{formatDecimal(channel.quantity, 4)}</td>
<td className="py-3 text-right">{formatMoney(channel.net_revenue, channel.currency)}</td>
<td className="py-3 text-right">{formatMoney(channel.cost_of_goods, channel.currency)}</td>
<td className="py-3 text-right">{formatMoney(channel.additional_costs, channel.currency)}</td>
<td className="py-3 text-right font-semibold">{formatMoney(channel.contribution_margin, channel.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
<article className="app-surface rounded-xl p-5">
<div className="mb-4 flex items-center gap-2">
<Boxes className="h-4 w-4" />
<h2 className="font-semibold">Остатки по источникам</h2>
</div>
{metrics.stock.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Снимков остатков пока нет.</p>
) : (
<div className="space-y-3">
{metrics.stock.map((stock) => (
<div key={`${stock.source}:${stock.warehouse_external_id}`} className="app-surface-muted rounded-lg p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{stock.warehouse_name || stock.warehouse_external_id}</p>
<p className="text-xs text-[var(--muted-foreground)]">{stock.source}</p>
</div>
<p className="text-sm font-semibold">{formatDecimal(stock.available, 4)} доступно</p>
</div>
<p className="mt-2 text-xs text-[var(--muted-foreground)]">
Всего {formatDecimal(stock.quantity, 4)} · резерв {formatDecimal(stock.reserved, 4)} · в пути {formatDecimal(stock.in_transit, 4)}
</p>
</div>
))}
</div>
)}
</article>
<article className="app-surface rounded-xl p-5">
<div className="mb-4 flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h2 className="font-semibold">Внешние соответствия</h2>
</div>
{product.bindings.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Привязки ещё не настроены.</p>
) : (
<div className="space-y-3">
{product.bindings.map((binding) => (
<div key={binding.id} className="app-surface-muted rounded-lg p-3">
<p className="text-sm font-medium">{binding.source_code}</p>
<p className="mt-1 break-all text-xs text-[var(--muted-foreground)]">
ID: {binding.external_id}
{binding.external_sku ? ` · SKU: ${binding.external_sku}` : ""}
</p>
</div>
))}
</div>
)}
</article>
</section>
</>
)}
</main>
</AppShell>
);
}
function Metric({
label,
value,
accent = false,
}: {
label: string;
value: string;
accent?: boolean;
}) {
return (
<div className="app-surface-muted rounded-lg p-3">
<p className="text-xs text-[var(--muted-foreground)]">{label}</p>
<p className={`mt-1 text-lg font-semibold ${accent ? "text-emerald-600" : "text-[var(--foreground)]"}`}>
{value}
</p>
</div>
);
}

View File

@ -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<CommerceProduct["kind"], string> = {
product: "Товар",
service: "Услуга",
bundle: "Комплект",
};
export default function ProductsPage() {
const [products, setProducts] = useState<CommerceProduct[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<AppShell>
<main className="mx-auto w-full max-w-7xl space-y-5 p-4 sm:p-6">
<header className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
Коммерческое ядро
</p>
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
Единый каталог товаров
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Внутренние SKU и их соответствия во внешних системах.
</p>
</header>
<label className="app-surface flex items-center gap-3 rounded-xl px-4 py-3">
<Search className="h-4 w-4 text-[var(--muted-foreground)]" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Название, артикул, штрихкод или внешний SKU"
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
</label>
{error ? (
<div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div>
) : null}
<section className="app-surface overflow-hidden rounded-xl">
{loading ? (
<p className="p-6 text-sm text-[var(--muted-foreground)]">Загрузка…</p>
) : products.length === 0 ? (
<div className="flex flex-col items-center gap-3 p-10 text-center">
<PackageSearch className="h-8 w-8 text-[var(--muted-foreground)]" />
<p className="text-sm text-[var(--muted-foreground)]">
Товары пока не добавлены или не найдены.
</p>
</div>
) : (
<div className="divide-y divide-[var(--border-subtle)]">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="flex items-center gap-4 px-4 py-4 transition hover:bg-[var(--surface-secondary)] sm:px-5"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-medium text-[var(--foreground)]">
{product.name}
</h2>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{kindLabels[product.kind]}
</span>
</div>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{product.sku}
{product.bindings.length > 0
? ` · ${product.bindings.length} интеграций`
: " · без внешних привязок"}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
</Link>
))}
</div>
)}
</section>
</main>
</AppShell>
);
}

View File

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

View File

@ -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<PaginatedCommerceResponse<CommerceProduct>> =>
request(`/api/v1/commerce/products/${buildQuery(params)}`),
getCommerceProduct: (id: string): Promise<CommerceProduct> =>
request(`/api/v1/commerce/products/${id}/`),
getCommerceProductMetrics: (
id: string,
params?: ProductMetricsParams,
): Promise<ProductMetrics> =>
request(`/api/v1/commerce/products/${id}/analytics/${buildQuery(params)}`),
};
}

View File

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

View File

@ -0,0 +1,78 @@
export type PaginatedCommerceResponse<T> = {
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<string, string>;
};
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<string, string>;
};
export type ProductMetrics = {
product_id: string;
period: {
from: string | null;
to: string | null;
};
monetary: ProductMonetaryMetrics[];
by_channel: ProductChannelMetrics[];
stock: ProductStockMetric[];
};