Add product-centric commerce analytics core
This commit is contained in:
parent
53cf1918c0
commit
a041d4844a
1
backend/api/v1/commerce/__init__.py
Normal file
1
backend/api/v1/commerce/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""REST API for product-centric commerce analytics."""
|
||||
30
backend/api/v1/commerce/permissions.py
Normal file
30
backend/api/v1/commerce/permissions.py
Normal 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}")
|
||||
180
backend/api/v1/commerce/serializers.py
Normal file
180
backend/api/v1/commerce/serializers.py
Normal 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
|
||||
22
backend/api/v1/commerce/urls.py
Normal file
22
backend/api/v1/commerce/urls.py
Normal 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
|
||||
121
backend/api/v1/commerce/viewsets.py
Normal file
121
backend/api/v1/commerce/viewsets.py
Normal 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"]
|
||||
@ -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
|
||||
|
||||
49
backend/commerce/README.md
Normal file
49
backend/commerce/README.md
Normal 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.
|
||||
1
backend/commerce/__init__.py
Normal file
1
backend/commerce/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Product-centric commerce analytics domain."""
|
||||
118
backend/commerce/admin.py
Normal file
118
backend/commerce/admin.py
Normal 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
7
backend/commerce/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommerceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "commerce"
|
||||
verbose_name = "Товарная аналитика"
|
||||
11
backend/commerce/integrations/__init__.py
Normal file
11
backend/commerce/integrations/__init__.py
Normal 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",
|
||||
]
|
||||
27
backend/commerce/integrations/base.py
Normal file
27
backend/commerce/integrations/base.py
Normal 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"}
|
||||
55
backend/commerce/integrations/registry.py
Normal file
55
backend/commerce/integrations/registry.py
Normal 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
|
||||
794
backend/commerce/migrations/0001_initial.py
Normal file
794
backend/commerce/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
0
backend/commerce/migrations/__init__.py
Normal file
0
backend/commerce/migrations/__init__.py
Normal file
19
backend/commerce/models/__init__.py
Normal file
19
backend/commerce/models/__init__.py
Normal 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",
|
||||
]
|
||||
76
backend/commerce/models/costs.py
Normal file
76
backend/commerce/models/costs.py
Normal 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}"
|
||||
60
backend/commerce/models/product.py
Normal file
60
backend/commerce/models/product.py
Normal 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}"
|
||||
151
backend/commerce/models/sales.py
Normal file
151
backend/commerce/models/sales.py
Normal 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
|
||||
106
backend/commerce/models/source.py
Normal file
106
backend/commerce/models/source.py
Normal 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}"
|
||||
61
backend/commerce/models/stock.py
Normal file
61
backend/commerce/models/stock.py
Normal 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}"
|
||||
13
backend/commerce/services/__init__.py
Normal file
13
backend/commerce/services/__init__.py
Normal 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",
|
||||
]
|
||||
80
backend/commerce/services/ingestion.py
Normal file
80
backend/commerce/services/ingestion.py
Normal 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)
|
||||
60
backend/commerce/services/matching.py
Normal file
60
backend/commerce/services/matching.py
Normal 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)
|
||||
238
backend/commerce/services/metrics.py
Normal file
238
backend/commerce/services/metrics.py
Normal 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),
|
||||
}
|
||||
13
backend/commerce/tasks/__init__.py
Normal file
13
backend/commerce/tasks/__init__.py
Normal 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),
|
||||
}
|
||||
@ -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
|
||||
|
||||
0
backend/tests/commerce/__init__.py
Normal file
0
backend/tests/commerce/__init__.py
Normal file
76
backend/tests/commerce/test_api.py
Normal file
76
backend/tests/commerce/test_api.py
Normal 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")
|
||||
180
backend/tests/commerce/test_services.py
Normal file
180
backend/tests/commerce/test_services.py
Normal 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")
|
||||
228
frontend/src/app/products/[productId]/page.tsx
Normal file
228
frontend/src/app/products/[productId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/app/products/page.tsx
Normal file
120
frontend/src/app/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
36
frontend/src/lib/api/commerce.ts
Normal file
36
frontend/src/lib/api/commerce.ts
Normal 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)}`),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
78
frontend/src/types/commerce.ts
Normal file
78
frontend/src/types/commerce.ts
Normal 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[];
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user