Complete commerce synchronization and realtime updates
This commit is contained in:
parent
b622370067
commit
156b649b04
@ -34,6 +34,7 @@ and [`frontend/package.json`](frontend/package.json).
|
||||
- [LDAP and Active Directory](docs/guides/LDAP.md)
|
||||
- [WebSocket and realtime](docs/guides/WEBSOCKET.md)
|
||||
- [Notifications](docs/guides/NOTIFICATIONS.md)
|
||||
- [Commerce and MoySklad](docs/guides/COMMERCE.md)
|
||||
|
||||
With the backend running, generated contracts are available at:
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ from commerce.models import (
|
||||
IntegrationSyncRun,
|
||||
Product,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
Sale,
|
||||
SaleLine,
|
||||
SourceSystem,
|
||||
@ -45,6 +46,9 @@ class IntegrationSyncRunSerializer(serializers.ModelSerializer):
|
||||
"created_products",
|
||||
"matched_products",
|
||||
"stock_snapshots",
|
||||
"images",
|
||||
"image_errors",
|
||||
"sales",
|
||||
"error",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
@ -202,6 +206,7 @@ class ProductBindingSerializer(serializers.ModelSerializer):
|
||||
"source_code",
|
||||
"external_id",
|
||||
"external_sku",
|
||||
"metadata",
|
||||
"is_primary",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
@ -209,8 +214,28 @@ class ProductBindingSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class ProductImageSerializer(serializers.ModelSerializer):
|
||||
url = serializers.ImageField(source="file", read_only=True)
|
||||
thumbnail_url = serializers.ImageField(source="thumbnail", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProductImage
|
||||
fields = [
|
||||
"id",
|
||||
"url",
|
||||
"thumbnail_url",
|
||||
"filename",
|
||||
"title",
|
||||
"source_size",
|
||||
"position",
|
||||
"is_primary",
|
||||
"source_updated_at",
|
||||
]
|
||||
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
bindings = ProductBindingSerializer(many=True, read_only=True)
|
||||
images = ProductImageSerializer(many=True, read_only=True)
|
||||
variants_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -226,6 +251,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
"is_active",
|
||||
"variants_count",
|
||||
"bindings",
|
||||
"images",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.db.models import Count
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, viewsets
|
||||
@ -45,6 +47,9 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntegrationConnectionViewSet(viewsets.ModelViewSet):
|
||||
queryset = IntegrationConnection.objects.select_related("source")
|
||||
serializer_class = IntegrationConnectionSerializer
|
||||
@ -57,6 +62,16 @@ class IntegrationConnectionViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
context = test_moysklad_connection(connection)
|
||||
except (MoySkladError, CredentialError) as exc:
|
||||
logger.warning(
|
||||
"Commerce connection test failed",
|
||||
extra={
|
||||
"event": "commerce.connection.test_failed",
|
||||
"connection_id": str(connection.pk),
|
||||
"provider": connection.provider,
|
||||
"error_type": type(exc).__name__,
|
||||
"failure_reason": str(exc),
|
||||
},
|
||||
)
|
||||
connection.status = IntegrationStatus.ERROR
|
||||
connection.last_error = str(exc)
|
||||
connection.last_tested_at = timezone.now()
|
||||
@ -93,6 +108,14 @@ class IntegrationConnectionViewSet(viewsets.ModelViewSet):
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
logger.info(
|
||||
"Commerce connection test succeeded",
|
||||
extra={
|
||||
"event": "commerce.connection.test_succeeded",
|
||||
"connection_id": str(connection.pk),
|
||||
"provider": connection.provider,
|
||||
},
|
||||
)
|
||||
return Response(self.get_serializer(connection).data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@ -132,6 +155,34 @@ class SourceSystemViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ["code", "name", "created_at"]
|
||||
ordering = ["name"]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
source = self.get_object()
|
||||
try:
|
||||
self.perform_destroy(source)
|
||||
except ProtectedError as exc:
|
||||
protected_count = len(exc.protected_objects)
|
||||
logger.info(
|
||||
"Commerce source deletion rejected because it is in use",
|
||||
extra={
|
||||
"event": "commerce.source.delete_conflict",
|
||||
"source_id": source.pk,
|
||||
"source_code": source.code,
|
||||
"protected_count": protected_count,
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"code": "source_in_use",
|
||||
"detail": (
|
||||
"Источник используется подключением или импортированными "
|
||||
"данными. Сначала удалите связанные записи."
|
||||
),
|
||||
"protected_count": protected_count,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProductViewSet(viewsets.ModelViewSet):
|
||||
queryset = Product.objects.all()
|
||||
@ -152,7 +203,7 @@ class ProductViewSet(viewsets.ModelViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("parent")
|
||||
.prefetch_related("bindings__source")
|
||||
.prefetch_related("bindings__source", "images")
|
||||
.annotate(variants_count=Count("variants", distinct=True))
|
||||
.distinct()
|
||||
)
|
||||
@ -232,8 +283,13 @@ class CostViewSet(viewsets.ModelViewSet):
|
||||
queryset = Cost.objects.select_related("source", "product", "sale_line")
|
||||
serializer_class = CostSerializer
|
||||
permission_classes = [CommerceAccessPermission]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filterset_fields = ["source", "product", "channel", "cost_type", "currency"]
|
||||
search_fields = ["external_id", "channel", "product__sku", "product__name"]
|
||||
ordering_fields = ["occurred_at", "amount", "created_at"]
|
||||
ordering = ["-occurred_at"]
|
||||
|
||||
@ -242,7 +298,17 @@ class StockSnapshotViewSet(viewsets.ModelViewSet):
|
||||
queryset = StockSnapshot.objects.select_related("source", "product")
|
||||
serializer_class = StockSnapshotSerializer
|
||||
permission_classes = [CommerceAccessPermission]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filterset_fields = ["source", "product", "warehouse_external_id"]
|
||||
search_fields = [
|
||||
"warehouse_external_id",
|
||||
"warehouse_name",
|
||||
"product__sku",
|
||||
"product__name",
|
||||
]
|
||||
ordering_fields = ["captured_at", "quantity", "created_at"]
|
||||
ordering = ["-captured_at"]
|
||||
|
||||
@ -6,6 +6,7 @@ from .models import (
|
||||
IntegrationSyncRun,
|
||||
Product,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
Sale,
|
||||
SaleLine,
|
||||
SourceRecord,
|
||||
@ -34,13 +35,27 @@ class ProductBindingInline(admin.TabularInline):
|
||||
autocomplete_fields = ["source"]
|
||||
|
||||
|
||||
class ProductImageInline(admin.TabularInline):
|
||||
model = ProductImage
|
||||
extra = 0
|
||||
readonly_fields = ["file", "thumbnail", "source_updated_at"]
|
||||
|
||||
|
||||
@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]
|
||||
inlines = [ProductBindingInline, ProductImageInline]
|
||||
|
||||
|
||||
@admin.register(ProductImage)
|
||||
class ProductImageAdmin(admin.ModelAdmin):
|
||||
list_display = ["product", "source", "filename", "is_primary", "position"]
|
||||
list_filter = ["source", "is_primary"]
|
||||
search_fields = ["product__sku", "product__name", "filename", "external_id"]
|
||||
autocomplete_fields = ["product", "source"]
|
||||
|
||||
|
||||
@admin.register(SourceSystem)
|
||||
|
||||
@ -5,3 +5,6 @@ class CommerceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "commerce"
|
||||
verbose_name = "Товарная аналитика"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
@ -9,8 +12,19 @@ class MoySkladError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoySkladRemoteImage:
|
||||
external_id: str
|
||||
filename: str
|
||||
title: str
|
||||
size: int
|
||||
updated: str
|
||||
download_href: str
|
||||
|
||||
|
||||
class MoySkladClient:
|
||||
DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2"
|
||||
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
||||
|
||||
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
|
||||
self.base_url = (
|
||||
@ -20,13 +34,18 @@ class MoySkladClient:
|
||||
+ "/"
|
||||
)
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept": "application/json;charset=utf-8",
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": "EUSRR-Commerce/1.0",
|
||||
}
|
||||
auth = None
|
||||
if auth_type == "token":
|
||||
token = credentials.get("token", "").strip()
|
||||
token = re.sub(
|
||||
r"^Bearer\s+",
|
||||
"",
|
||||
credentials.get("token", "").strip(),
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
if not token:
|
||||
raise MoySkladError("Не указан токен МойСклад.")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
@ -62,12 +81,17 @@ class MoySkladClient:
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
detail = self._error_detail(exc.response)
|
||||
if status in {401, 403}:
|
||||
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
|
||||
elif status == 400:
|
||||
message = "МойСклад не принял запрос. Проверьте access token и права пользователя."
|
||||
elif status == 429:
|
||||
message = "МойСклад временно ограничил частоту запросов."
|
||||
else:
|
||||
message = f"МойСклад вернул ошибку HTTP {status}."
|
||||
if detail:
|
||||
message = f"{message} Ответ сервиса: {detail}"
|
||||
raise MoySkladError(message) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise MoySkladError(
|
||||
@ -77,15 +101,128 @@ class MoySkladClient:
|
||||
raise MoySkladError("МойСклад вернул ответ неожиданного формата.")
|
||||
return payload
|
||||
|
||||
def product_images(
|
||||
self,
|
||||
entity_type: str,
|
||||
external_id: str,
|
||||
) -> list[MoySkladRemoteImage]:
|
||||
if entity_type not in {"product", "variant", "bundle"}:
|
||||
return []
|
||||
payload = self._get(f"/entity/{entity_type}/{external_id}/images")
|
||||
rows = payload.get("rows") or []
|
||||
if not isinstance(rows, list):
|
||||
raise MoySkladError("МойСклад вернул некорректный список изображений.")
|
||||
|
||||
return self.parse_product_images(rows)
|
||||
|
||||
@staticmethod
|
||||
def parse_product_images(rows: list[dict]) -> list[MoySkladRemoteImage]:
|
||||
images = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
|
||||
href = str(meta.get("href") or "").rstrip("/")
|
||||
download_href = str(meta.get("downloadHref") or "").strip()
|
||||
image_id = href.rsplit("/", 1)[-1]
|
||||
if not image_id or not download_href:
|
||||
continue
|
||||
images.append(
|
||||
MoySkladRemoteImage(
|
||||
external_id=image_id[:255],
|
||||
filename=str(row.get("filename") or "image")[:255],
|
||||
title=str(row.get("title") or "")[:255],
|
||||
size=max(0, int(row.get("size") or 0)),
|
||||
updated=str(row.get("updated") or ""),
|
||||
download_href=download_href,
|
||||
)
|
||||
)
|
||||
return images
|
||||
|
||||
def download_image(self, download_href: str) -> tuple[bytes, str]:
|
||||
source = urlparse(download_href)
|
||||
api = urlparse(self.base_url)
|
||||
if source.scheme != "https" or source.netloc != api.netloc:
|
||||
raise MoySkladError("МойСклад вернул недопустимую ссылку на изображение.")
|
||||
|
||||
try:
|
||||
with self.client.stream(
|
||||
"GET",
|
||||
download_href,
|
||||
follow_redirects=True,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
declared_size = int(response.headers.get("Content-Length") or 0)
|
||||
if declared_size > self.MAX_IMAGE_BYTES:
|
||||
raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.")
|
||||
chunks = []
|
||||
received = 0
|
||||
for chunk in response.iter_bytes():
|
||||
received += len(chunk)
|
||||
if received > self.MAX_IMAGE_BYTES:
|
||||
raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.")
|
||||
chunks.append(chunk)
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
|
||||
except MoySkladError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise MoySkladError("Не удалось скачать изображение из МойСклад.") from exc
|
||||
|
||||
if not chunks:
|
||||
raise MoySkladError("МойСклад вернул пустое изображение.")
|
||||
if content_type and not (
|
||||
content_type.startswith("image/")
|
||||
or content_type == "application/octet-stream"
|
||||
):
|
||||
raise MoySkladError("МойСклад вернул файл неподдерживаемого типа.")
|
||||
return b"".join(chunks), content_type
|
||||
|
||||
@staticmethod
|
||||
def _error_detail(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return ""
|
||||
candidates: list[Any] = []
|
||||
if isinstance(payload, dict):
|
||||
errors = payload.get("errors")
|
||||
if isinstance(errors, list):
|
||||
candidates.extend(errors)
|
||||
candidates.append(payload)
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
for key in ("error", "message", "detail"):
|
||||
value = candidate.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return " ".join(value.split())[:500]
|
||||
return ""
|
||||
|
||||
def employee_context(self) -> dict:
|
||||
return self._get("/context/employee")
|
||||
|
||||
def iter_assortment(self, *, limit: int = 1000) -> Iterator[dict]:
|
||||
def sales_channel_names(self) -> dict[str, str]:
|
||||
payload = self._get("/entity/saleschannel", params={"limit": 100})
|
||||
rows = payload.get("rows") or []
|
||||
if not isinstance(rows, list):
|
||||
raise MoySkladError("МойСклад вернул некорректный список каналов продаж.")
|
||||
return {
|
||||
str(row.get("id")): str(row.get("name") or "")
|
||||
for row in rows
|
||||
if isinstance(row, dict) and row.get("id")
|
||||
}
|
||||
|
||||
def iter_assortment(self, *, limit: int = 100) -> Iterator[dict]:
|
||||
offset = 0
|
||||
while True:
|
||||
payload = self._get(
|
||||
"/entity/assortment",
|
||||
params={"limit": limit, "offset": offset, "stockMode": "all"},
|
||||
params={
|
||||
"limit": min(limit, 100),
|
||||
"offset": offset,
|
||||
"stockMode": "all",
|
||||
"expand": "images",
|
||||
},
|
||||
)
|
||||
rows = payload.get("rows", [])
|
||||
if not isinstance(rows, list):
|
||||
@ -93,9 +230,9 @@ class MoySkladClient:
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
yield row
|
||||
if len(rows) < limit:
|
||||
if len(rows) < min(limit, 100):
|
||||
return
|
||||
offset += limit
|
||||
offset += min(limit, 100)
|
||||
|
||||
def iter_documents(self, entity_type: str, *, limit: int = 100) -> Iterator[dict]:
|
||||
if entity_type not in {"demand", "salesreturn"}:
|
||||
@ -107,7 +244,7 @@ class MoySkladClient:
|
||||
params={
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"expand": "positions.assortment,state",
|
||||
"expand": "positions.assortment,state,salesChannel,rate.currency",
|
||||
},
|
||||
)
|
||||
rows = payload.get("rows", [])
|
||||
@ -119,3 +256,21 @@ class MoySkladClient:
|
||||
if len(rows) < limit:
|
||||
return
|
||||
offset += limit
|
||||
|
||||
def iter_stock_by_store(self, *, limit: int = 1000) -> Iterator[dict]:
|
||||
offset = 0
|
||||
while True:
|
||||
page_size = min(limit, 1000)
|
||||
payload = self._get(
|
||||
"/report/stock/bystore",
|
||||
params={"limit": page_size, "offset": offset},
|
||||
)
|
||||
rows = payload.get("rows", [])
|
||||
if not isinstance(rows, list):
|
||||
raise MoySkladError("В отчёте остатков отсутствует список rows.")
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
yield row
|
||||
if len(rows) < page_size:
|
||||
return
|
||||
offset += page_size
|
||||
|
||||
1
backend/commerce/management/__init__.py
Normal file
1
backend/commerce/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/commerce/management/commands/__init__.py
Normal file
1
backend/commerce/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
158
backend/commerce/management/commands/repair_moysklad_data.py
Normal file
158
backend/commerce/management/commands/repair_moysklad_data.py
Normal file
@ -0,0 +1,158 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
|
||||
from commerce.media import sync_moysklad_product_images
|
||||
from commerce.models import (
|
||||
IntegrationConnection,
|
||||
IntegrationSyncRun,
|
||||
IntegrationStatus,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
Sale,
|
||||
SourceRecord,
|
||||
StockSnapshot,
|
||||
)
|
||||
from commerce.services.credentials import CredentialError, decrypt_credentials
|
||||
from commerce.services.moysklad_sync import (
|
||||
_document_channel,
|
||||
normalize_moysklad_stock_row,
|
||||
reconcile_moysklad_variant_parents,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Восстанавливает нормализованные данные МойСклад из SourceRecord и API."
|
||||
|
||||
def handle(self, *args, **options): # noqa: C901 - orchestration command
|
||||
try:
|
||||
connection = IntegrationConnection.objects.select_related("source").get(
|
||||
provider="moysklad"
|
||||
)
|
||||
credentials = decrypt_credentials(connection)
|
||||
except IntegrationConnection.DoesNotExist as exc:
|
||||
raise CommandError("Подключение МойСклад не найдено.") from exc
|
||||
except CredentialError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
source = connection.source
|
||||
bindings = {
|
||||
binding.external_id: binding
|
||||
for binding in ProductBinding.objects.filter(source=source).select_related(
|
||||
"product"
|
||||
)
|
||||
}
|
||||
existing_images = set(
|
||||
ProductImage.objects.filter(source=source).values_list(
|
||||
"product_id", "external_id"
|
||||
)
|
||||
)
|
||||
image_rows = []
|
||||
for record in SourceRecord.objects.filter(
|
||||
source=source,
|
||||
entity_type="product",
|
||||
).only("external_id", "payload"):
|
||||
binding = bindings.get(record.external_id)
|
||||
if binding is None:
|
||||
continue
|
||||
images = (
|
||||
record.payload.get("images")
|
||||
if isinstance(record.payload.get("images"), dict)
|
||||
else {}
|
||||
)
|
||||
remote_ids = []
|
||||
for image in images.get("rows") or []:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
href = str((image.get("meta") or {}).get("href") or "").rstrip("/")
|
||||
image_id = href.rsplit("/", 1)[-1]
|
||||
if image_id:
|
||||
remote_ids.append(image_id)
|
||||
if any(
|
||||
(binding.product_id, image_id) not in existing_images
|
||||
for image_id in remote_ids
|
||||
):
|
||||
image_rows.append(record.payload)
|
||||
|
||||
repaired_images = 0
|
||||
image_errors = 0
|
||||
stock_snapshots = 0
|
||||
updated_channels = 0
|
||||
captured_at = timezone.now()
|
||||
try:
|
||||
with MoySkladClient(
|
||||
auth_type=connection.auth_type,
|
||||
credentials=credentials,
|
||||
) as client:
|
||||
channel_names = client.sales_channel_names()
|
||||
for record in SourceRecord.objects.filter(
|
||||
source=source,
|
||||
entity_type__in=["demand", "salesreturn"],
|
||||
).only("entity_type", "external_id", "payload"):
|
||||
channel = _document_channel(record.payload, channel_names)
|
||||
updated_channels += Sale.objects.filter(
|
||||
source=source,
|
||||
external_id=f"{record.entity_type}:{record.external_id}",
|
||||
).exclude(channel=channel).update(channel=channel)
|
||||
|
||||
for row in image_rows:
|
||||
try:
|
||||
repaired_images += sync_moysklad_product_images(
|
||||
connection=connection,
|
||||
row=row,
|
||||
client=client,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - continue repairing other images
|
||||
image_errors += 1
|
||||
|
||||
for row in client.iter_stock_by_store():
|
||||
stock_snapshots += normalize_moysklad_stock_row(
|
||||
connection=connection,
|
||||
row=row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
except MoySkladError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
with transaction.atomic():
|
||||
deleted_aggregate_stock, _ = StockSnapshot.objects.filter(
|
||||
source=source,
|
||||
warehouse_external_id="all",
|
||||
).delete()
|
||||
reconciled_parents = reconcile_moysklad_variant_parents(source)
|
||||
IntegrationSyncRun.objects.filter(
|
||||
connection=connection,
|
||||
status__in=["queued", "running"],
|
||||
).update(
|
||||
status="error",
|
||||
error="Предыдущая синхронизация была прервана; данные восстановлены.",
|
||||
finished_at=timezone.now(),
|
||||
)
|
||||
connection.status = IntegrationStatus.READY
|
||||
connection.last_error = ""
|
||||
connection.next_sync_at = timezone.now() + timedelta(
|
||||
minutes=connection.sync_interval_minutes
|
||||
)
|
||||
connection.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"last_error",
|
||||
"next_sync_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Восстановление завершено: "
|
||||
f"каналы={updated_channels}, "
|
||||
f"изображения={repaired_images}, "
|
||||
f"ошибки изображений={image_errors}, "
|
||||
f"остатки={stock_snapshots}, "
|
||||
f"удалено агрегированных остатков={deleted_aggregate_stock}, "
|
||||
f"родительские связи={reconciled_parents}."
|
||||
)
|
||||
)
|
||||
189
backend/commerce/media.py
Normal file
189
backend/commerce/media.py
Normal file
@ -0,0 +1,189 @@
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
|
||||
from dateutil import parser as date_parser
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladClient
|
||||
from commerce.models import IntegrationConnection, ProductImage
|
||||
|
||||
|
||||
PRODUCT_THUMBNAIL_SIZE = (480, 480)
|
||||
MAX_PRODUCT_IMAGE_PIXELS = 40_000_000
|
||||
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
||||
|
||||
|
||||
def _source_updated_at(value: str):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = date_parser.parse(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if timezone.is_naive(parsed):
|
||||
parsed = timezone.make_aware(parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validated_image_and_thumbnail(content: bytes) -> tuple[bytes, str]:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(content)) as probe:
|
||||
if probe.width * probe.height > MAX_PRODUCT_IMAGE_PIXELS:
|
||||
raise ValueError("Изображение превышает лимит 40 мегапикселей.")
|
||||
image_format = (probe.format or "").upper()
|
||||
probe.verify()
|
||||
with Image.open(io.BytesIO(content)) as source:
|
||||
source.seek(0)
|
||||
image = ImageOps.exif_transpose(source)
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA" if "transparency" in image.info else "RGB")
|
||||
image.thumbnail(PRODUCT_THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="WEBP", quality=84, method=5)
|
||||
suffix = {
|
||||
"JPEG": ".jpg",
|
||||
"PNG": ".png",
|
||||
"WEBP": ".webp",
|
||||
"GIF": ".gif",
|
||||
"BMP": ".bmp",
|
||||
}.get(image_format, ".img")
|
||||
return output.getvalue(), suffix
|
||||
|
||||
|
||||
def _storage_filename(
|
||||
external_id: str,
|
||||
source_name: str,
|
||||
content: bytes,
|
||||
detected_suffix: str,
|
||||
) -> str:
|
||||
suffix = Path(source_name).suffix.lower()
|
||||
if suffix not in IMAGE_SUFFIXES:
|
||||
suffix = detected_suffix
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return f"{external_id}-{digest}{suffix}"
|
||||
|
||||
|
||||
def _delete_replaced_files(image, old_names):
|
||||
for field_name, old_name in old_names.items():
|
||||
field = getattr(image, field_name)
|
||||
if old_name and old_name != field.name:
|
||||
field.storage.delete(old_name)
|
||||
|
||||
|
||||
def sync_moysklad_product_images(
|
||||
*,
|
||||
connection: IntegrationConnection,
|
||||
row: dict,
|
||||
client: MoySkladClient,
|
||||
) -> int:
|
||||
external_product_id = str(row.get("id") or "").strip()
|
||||
if not external_product_id:
|
||||
return 0
|
||||
binding = connection.source.product_bindings.select_related("product").get(
|
||||
external_id=external_product_id
|
||||
)
|
||||
product = binding.product
|
||||
images_meta = row.get("images") if isinstance(row.get("images"), dict) else {}
|
||||
collection_meta = (
|
||||
images_meta.get("meta") if isinstance(images_meta.get("meta"), dict) else {}
|
||||
)
|
||||
image_count = max(0, int(collection_meta.get("size") or 0))
|
||||
if image_count == 0:
|
||||
product.images.filter(source=connection.source).delete()
|
||||
return 0
|
||||
|
||||
entity_meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
|
||||
entity_type = str(entity_meta.get("type") or "")
|
||||
embedded_rows = images_meta.get("rows")
|
||||
remote_images = (
|
||||
client.parse_product_images(embedded_rows)
|
||||
if isinstance(embedded_rows, list)
|
||||
else client.product_images(entity_type, external_product_id)
|
||||
)
|
||||
retained_ids = []
|
||||
|
||||
for position, remote in enumerate(remote_images):
|
||||
source_updated_at = _source_updated_at(remote.updated)
|
||||
image = ProductImage.objects.filter(
|
||||
product=product,
|
||||
source=connection.source,
|
||||
external_id=remote.external_id,
|
||||
).first()
|
||||
unchanged = bool(
|
||||
image
|
||||
and image.source_size == remote.size
|
||||
and image.source_updated_at == source_updated_at
|
||||
and image.file
|
||||
and image.file.storage.exists(image.file.name)
|
||||
and image.thumbnail
|
||||
and image.thumbnail.storage.exists(image.thumbnail.name)
|
||||
)
|
||||
if unchanged:
|
||||
image.product = product
|
||||
image.filename = remote.filename
|
||||
image.title = remote.title
|
||||
image.position = position
|
||||
image.is_primary = position == 0
|
||||
image.save(
|
||||
update_fields=[
|
||||
"product",
|
||||
"filename",
|
||||
"title",
|
||||
"position",
|
||||
"is_primary",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
retained_ids.append(remote.external_id)
|
||||
continue
|
||||
|
||||
content, _content_type = client.download_image(remote.download_href)
|
||||
thumbnail, detected_suffix = _validated_image_and_thumbnail(content)
|
||||
storage_name = _storage_filename(
|
||||
remote.external_id,
|
||||
remote.filename,
|
||||
content,
|
||||
detected_suffix,
|
||||
)
|
||||
if image is None:
|
||||
image = ProductImage(
|
||||
source=connection.source,
|
||||
external_id=remote.external_id,
|
||||
)
|
||||
old_names = {
|
||||
"file": image.file.name if image.file else "",
|
||||
"thumbnail": image.thumbnail.name if image.thumbnail else "",
|
||||
}
|
||||
image.product = product
|
||||
image.filename = remote.filename
|
||||
image.title = remote.title
|
||||
image.source_size = remote.size
|
||||
image.source_updated_at = source_updated_at
|
||||
image.position = position
|
||||
image.is_primary = position == 0
|
||||
image.file.save(storage_name, ContentFile(content), save=False)
|
||||
image.thumbnail.save(
|
||||
f"{remote.external_id}-{hashlib.sha256(content).hexdigest()[:16]}.webp",
|
||||
ContentFile(thumbnail),
|
||||
save=False,
|
||||
)
|
||||
with transaction.atomic():
|
||||
image.save()
|
||||
transaction.on_commit(
|
||||
lambda current=image, previous=old_names: _delete_replaced_files(
|
||||
current,
|
||||
previous,
|
||||
)
|
||||
)
|
||||
retained_ids.append(remote.external_id)
|
||||
|
||||
product.images.filter(source=connection.source).exclude(
|
||||
external_id__in=retained_ids
|
||||
).delete()
|
||||
return len(retained_ids)
|
||||
@ -0,0 +1,102 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-17
|
||||
|
||||
import commerce.models.product
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("commerce", "0003_integrationconnection_auto_sync_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="integrationsyncrun",
|
||||
name="images",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="Изображений"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="integrationsyncrun",
|
||||
name="sales",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Продаж и возвратов",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProductImage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("external_id", models.CharField(max_length=255, verbose_name="Внешний ID")),
|
||||
(
|
||||
"file",
|
||||
models.ImageField(
|
||||
upload_to=commerce.models.product.product_image_upload_to,
|
||||
verbose_name="Оригинал",
|
||||
),
|
||||
),
|
||||
(
|
||||
"thumbnail",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
upload_to=commerce.models.product.product_thumbnail_upload_to,
|
||||
verbose_name="Миниатюра",
|
||||
),
|
||||
),
|
||||
("filename", models.CharField(blank=True, max_length=255, verbose_name="Имя файла")),
|
||||
("title", models.CharField(blank=True, max_length=255, verbose_name="Название")),
|
||||
("source_size", models.PositiveBigIntegerField(default=0, verbose_name="Размер в источнике")),
|
||||
("source_updated_at", models.DateTimeField(blank=True, null=True, verbose_name="Изменено в источнике")),
|
||||
("position", models.PositiveIntegerField(default=0, 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="images",
|
||||
to="commerce.product",
|
||||
verbose_name="Товар",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="product_images",
|
||||
to="commerce.sourcesystem",
|
||||
verbose_name="Источник",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Изображение товара",
|
||||
"verbose_name_plural": "Изображения товаров",
|
||||
"ordering": ["position", "created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["product", "is_primary", "position"],
|
||||
name="commerce_product_image_order",
|
||||
)
|
||||
],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("source", "external_id"),
|
||||
name="commerce_product_image_source_external",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-17
|
||||
|
||||
import commerce.models.product
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("commerce", "0004_productimage_integrationsyncrun_progress"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="integrationsyncrun",
|
||||
name="image_errors",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Ошибок изображений",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="productimage",
|
||||
name="file",
|
||||
field=models.ImageField(
|
||||
max_length=500,
|
||||
upload_to=commerce.models.product.product_image_upload_to,
|
||||
verbose_name="Оригинал",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="productimage",
|
||||
name="thumbnail",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
upload_to=commerce.models.product.product_thumbnail_upload_to,
|
||||
verbose_name="Миниатюра",
|
||||
),
|
||||
),
|
||||
]
|
||||
326
backend/commerce/migrations/0006_repair_moysklad_catalog.py
Normal file
326
backend/commerce/migrations/0006_repair_moysklad_catalog.py
Normal file
@ -0,0 +1,326 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-18
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def _external_id(value):
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
direct = str(value.get("id") or "").strip()
|
||||
if direct:
|
||||
return direct
|
||||
meta = value.get("meta") if isinstance(value.get("meta"), dict) else {}
|
||||
href = str(meta.get("href") or "").rstrip("/")
|
||||
return href.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _decimal(value):
|
||||
try:
|
||||
return Decimal(str(value or 0))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _money(value):
|
||||
return (_decimal(value) / Decimal("100")).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def _barcode(row):
|
||||
for item in row.get("barcodes") or []:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
if isinstance(item, dict):
|
||||
for value in item.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _kind(row):
|
||||
source_type = str((row.get("meta") or {}).get("type") or "")
|
||||
return {"service": "service", "bundle": "bundle"}.get(source_type, "product")
|
||||
|
||||
|
||||
def _unique_sku(row, external_id, used):
|
||||
raw = str(row.get("article") or row.get("code") or "").strip()
|
||||
raw = re.sub(r"\s+", "-", raw or f"moysklad-{external_id}")[:128]
|
||||
candidate = raw
|
||||
if candidate.casefold() not in used:
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
suffix = external_id.replace("-", "")[:10]
|
||||
base = raw[: max(1, 117 - len(suffix))]
|
||||
candidate = f"{base}-ms-{suffix}"[:128]
|
||||
sequence = 2
|
||||
while candidate.casefold() in used:
|
||||
tail = f"-{sequence}"
|
||||
candidate = f"{base[: 128 - len(tail)]}{tail}"
|
||||
sequence += 1
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
|
||||
|
||||
def _metadata(row):
|
||||
def price_value(value):
|
||||
if not isinstance(value, dict) or "value" not in value:
|
||||
return None
|
||||
return str(_money(value.get("value")))
|
||||
|
||||
sale_prices = []
|
||||
for price in row.get("salePrices") or []:
|
||||
if not isinstance(price, dict):
|
||||
continue
|
||||
price_type = price.get("priceType") if isinstance(price.get("priceType"), dict) else {}
|
||||
sale_prices.append(
|
||||
{
|
||||
"name": str(price_type.get("name") or ""),
|
||||
"external_id": _external_id(price_type),
|
||||
"value": str(_money(price.get("value"))),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"provider": "moysklad",
|
||||
"entity_type": str((row.get("meta") or {}).get("type") or ""),
|
||||
"external_code": str(row.get("externalCode") or ""),
|
||||
"parent_external_id": _external_id(row.get("product")),
|
||||
"purchase_price": price_value(row.get("buyPrice")),
|
||||
"minimum_price": price_value(row.get("minPrice")),
|
||||
"sale_prices": sale_prices,
|
||||
"uom": {
|
||||
"external_id": _external_id(row.get("uom")),
|
||||
"name": str((row.get("uom") or {}).get("name") or "")
|
||||
if isinstance(row.get("uom"), dict)
|
||||
else "",
|
||||
},
|
||||
"folder": {
|
||||
"external_id": _external_id(row.get("productFolder")),
|
||||
"name": str(row.get("pathName") or ""),
|
||||
},
|
||||
"weight": row.get("weight"),
|
||||
"volume": row.get("volume"),
|
||||
"vat": row.get("effectiveVat", row.get("vat")),
|
||||
"attributes": row.get("attributes") or [],
|
||||
"characteristics": row.get("characteristics") or [],
|
||||
"source_updated_at": str(row.get("updated") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _update_product(product, row):
|
||||
external_id = str(row.get("id") or "")
|
||||
external_sku = str(row.get("article") or row.get("code") or "").strip()
|
||||
product.name = str(row.get("name") or external_sku or external_id)[:255]
|
||||
product.barcode = _barcode(row)[:64]
|
||||
product.description = str(row.get("description") or "")
|
||||
product.kind = _kind(row)
|
||||
product.is_active = not bool(row.get("archived"))
|
||||
product.save(update_fields=["name", "barcode", "description", "kind", "is_active", "updated_at"])
|
||||
|
||||
|
||||
def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair
|
||||
SourceSystem = apps.get_model("commerce", "SourceSystem")
|
||||
SourceRecord = apps.get_model("commerce", "SourceRecord")
|
||||
Product = apps.get_model("commerce", "Product")
|
||||
ProductBinding = apps.get_model("commerce", "ProductBinding")
|
||||
ProductImage = apps.get_model("commerce", "ProductImage")
|
||||
Sale = apps.get_model("commerce", "Sale")
|
||||
SaleLine = apps.get_model("commerce", "SaleLine")
|
||||
StockSnapshot = apps.get_model("commerce", "StockSnapshot")
|
||||
|
||||
source = SourceSystem.objects.filter(code="moysklad").first()
|
||||
if source is None:
|
||||
return
|
||||
|
||||
product_records = {
|
||||
record.external_id: record.payload
|
||||
for record in SourceRecord.objects.filter(source=source, entity_type="product")
|
||||
}
|
||||
used_skus = {value.casefold() for value in Product.objects.values_list("sku", flat=True)}
|
||||
grouped = defaultdict(list)
|
||||
for binding in ProductBinding.objects.filter(source=source).select_related("product"):
|
||||
grouped[binding.product_id].append(binding)
|
||||
|
||||
type_priority = {"product": 0, "bundle": 1, "service": 2, "variant": 3}
|
||||
for bindings in grouped.values():
|
||||
bindings.sort(
|
||||
key=lambda binding: (
|
||||
type_priority.get(
|
||||
str((product_records.get(binding.external_id, {}).get("meta") or {}).get("type") or ""),
|
||||
9,
|
||||
),
|
||||
str(binding.created_at),
|
||||
)
|
||||
)
|
||||
keeper = bindings[0]
|
||||
keeper_raw = product_records.get(keeper.external_id, {})
|
||||
keeper.metadata = _metadata(keeper_raw)
|
||||
keeper.external_sku = str(keeper_raw.get("article") or keeper_raw.get("code") or "")[:128]
|
||||
keeper.save(update_fields=["metadata", "external_sku", "updated_at"])
|
||||
if keeper_raw:
|
||||
_update_product(keeper.product, keeper_raw)
|
||||
|
||||
for binding in bindings[1:]:
|
||||
raw = product_records.get(binding.external_id, {})
|
||||
product = Product.objects.create(
|
||||
sku=_unique_sku(raw, binding.external_id, used_skus),
|
||||
name=str(raw.get("name") or binding.external_sku or binding.external_id)[:255],
|
||||
barcode=_barcode(raw)[:64],
|
||||
description=str(raw.get("description") or ""),
|
||||
kind=_kind(raw),
|
||||
is_active=not bool(raw.get("archived")),
|
||||
)
|
||||
binding.product = product
|
||||
binding.external_sku = str(raw.get("article") or raw.get("code") or "")[:128]
|
||||
binding.metadata = _metadata(raw)
|
||||
binding.save(update_fields=["product", "external_sku", "metadata", "updated_at"])
|
||||
SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
external_product_id=binding.external_id,
|
||||
).update(product=product)
|
||||
StockSnapshot.objects.filter(
|
||||
source=source,
|
||||
metadata__external_product_id=binding.external_id,
|
||||
).update(product=product)
|
||||
|
||||
bindings_by_external = {
|
||||
binding.external_id: binding
|
||||
for binding in ProductBinding.objects.filter(source=source).select_related("product")
|
||||
}
|
||||
for binding in bindings_by_external.values():
|
||||
raw = product_records.get(binding.external_id, {})
|
||||
metadata = _metadata(raw)
|
||||
if binding.metadata != metadata:
|
||||
binding.metadata = metadata
|
||||
binding.save(update_fields=["metadata", "updated_at"])
|
||||
parent_binding = bindings_by_external.get(str(metadata.get("parent_external_id") or ""))
|
||||
if parent_binding and parent_binding.product_id != binding.product_id:
|
||||
Product.objects.filter(pk=binding.product_id).update(parent_id=parent_binding.product_id)
|
||||
SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
external_product_id=binding.external_id,
|
||||
).update(product_id=binding.product_id)
|
||||
|
||||
desired_images = defaultdict(set)
|
||||
for external_id, raw in product_records.items():
|
||||
binding = bindings_by_external.get(external_id)
|
||||
if binding is None:
|
||||
continue
|
||||
images = raw.get("images") if isinstance(raw.get("images"), dict) else {}
|
||||
for image_row in images.get("rows") or []:
|
||||
image_id = _external_id(image_row)
|
||||
if image_id:
|
||||
desired_images[image_id].add(binding.product_id)
|
||||
for image_id, product_ids in desired_images.items():
|
||||
originals = list(ProductImage.objects.filter(source=source, external_id=image_id))
|
||||
if not originals:
|
||||
continue
|
||||
original = originals[0]
|
||||
for product_id in product_ids:
|
||||
if ProductImage.objects.filter(
|
||||
product_id=product_id,
|
||||
source=source,
|
||||
external_id=image_id,
|
||||
).exists():
|
||||
continue
|
||||
ProductImage.objects.create(
|
||||
product_id=product_id,
|
||||
source=source,
|
||||
external_id=image_id,
|
||||
file=original.file,
|
||||
thumbnail=original.thumbnail,
|
||||
filename=original.filename,
|
||||
title=original.title,
|
||||
source_size=original.source_size,
|
||||
source_updated_at=original.source_updated_at,
|
||||
position=original.position,
|
||||
is_primary=original.is_primary,
|
||||
)
|
||||
ProductImage.objects.filter(source=source, external_id=image_id).exclude(
|
||||
product_id__in=product_ids
|
||||
).delete()
|
||||
|
||||
sales = {
|
||||
sale.external_id: sale
|
||||
for sale in Sale.objects.filter(source=source)
|
||||
}
|
||||
for record in SourceRecord.objects.filter(
|
||||
source=source,
|
||||
entity_type__in=["demand", "salesreturn"],
|
||||
).iterator(chunk_size=250):
|
||||
sale = sales.get(f"{record.entity_type}:{record.external_id}")
|
||||
if sale is None:
|
||||
continue
|
||||
sales_channel = record.payload.get("salesChannel")
|
||||
sale.channel = (
|
||||
str(sales_channel.get("name") or "Не указан")[:64]
|
||||
if isinstance(sales_channel, dict)
|
||||
else "Не указан"
|
||||
)
|
||||
sale.save(update_fields=["channel", "updated_at"])
|
||||
positions = record.payload.get("positions") or []
|
||||
if isinstance(positions, dict):
|
||||
positions = positions.get("rows") or []
|
||||
for index, position in enumerate(positions, start=1):
|
||||
if not isinstance(position, dict):
|
||||
continue
|
||||
position_id = str(position.get("id") or index)
|
||||
assortment = position.get("assortment") if isinstance(position.get("assortment"), dict) else {}
|
||||
product_external_id = _external_id(assortment)
|
||||
binding = bindings_by_external.get(product_external_id)
|
||||
quantity = _decimal(position.get("quantity"))
|
||||
position_cost = _money(position.get("cost"))
|
||||
buy_price = assortment.get("buyPrice") if isinstance(assortment.get("buyPrice"), dict) else {}
|
||||
unit_cost = position_cost if position_cost > 0 else _money(buy_price.get("value"))
|
||||
line_updates = {
|
||||
"cost_amount": (quantity * unit_cost).quantize(Decimal("0.01")),
|
||||
"metadata": {
|
||||
"provider": "moysklad",
|
||||
"cost_source": (
|
||||
"position.cost"
|
||||
if position_cost > 0
|
||||
else ("assortment.buyPrice" if unit_cost > 0 else "unavailable")
|
||||
),
|
||||
"vat": position.get("vat"),
|
||||
"vat_enabled": bool(position.get("vatEnabled")),
|
||||
"overhead": str(_money(position.get("overhead"))),
|
||||
},
|
||||
}
|
||||
if binding:
|
||||
line_updates["product_id"] = binding.product_id
|
||||
SaleLine.objects.filter(sale=sale, external_id=position_id).update(
|
||||
**line_updates
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("commerce", "0005_productimage_paths_and_image_errors"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="productimage",
|
||||
name="commerce_product_image_source_external",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="productbinding",
|
||||
name="metadata",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Нормализованные данные источника",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(repair_moysklad_catalog, migrations.RunPython.noop),
|
||||
migrations.AddConstraint(
|
||||
model_name="productimage",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("product", "source", "external_id"),
|
||||
name="commerce_product_image_identity",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,54 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def recover_sale_line_matches(apps, schema_editor):
|
||||
SourceSystem = apps.get_model("commerce", "SourceSystem")
|
||||
Product = apps.get_model("commerce", "Product")
|
||||
ProductBinding = apps.get_model("commerce", "ProductBinding")
|
||||
SaleLine = apps.get_model("commerce", "SaleLine")
|
||||
|
||||
source = SourceSystem.objects.filter(code="moysklad").first()
|
||||
if source is None:
|
||||
return
|
||||
|
||||
lines = SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
product__isnull=True,
|
||||
).only("id", "external_product_id", "external_sku")
|
||||
for line in lines.iterator(chunk_size=250):
|
||||
product_id = None
|
||||
if line.external_product_id:
|
||||
product_id = ProductBinding.objects.filter(
|
||||
source=source,
|
||||
external_id=line.external_product_id,
|
||||
).values_list("product_id", flat=True).first()
|
||||
if product_id is None and line.external_sku:
|
||||
binding_matches = list(
|
||||
ProductBinding.objects.filter(
|
||||
source=source,
|
||||
external_sku__iexact=line.external_sku,
|
||||
).values_list("product_id", flat=True)[:2]
|
||||
)
|
||||
if len(binding_matches) == 1:
|
||||
product_id = binding_matches[0]
|
||||
else:
|
||||
product_matches = list(
|
||||
Product.objects.filter(sku__iexact=line.external_sku).values_list(
|
||||
"id", flat=True
|
||||
)[:2]
|
||||
)
|
||||
if len(product_matches) == 1:
|
||||
product_id = product_matches[0]
|
||||
if product_id is not None:
|
||||
SaleLine.objects.filter(pk=line.pk).update(product_id=product_id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("commerce", "0006_repair_moysklad_catalog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(recover_sale_line_matches, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,86 @@
|
||||
import re
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def _unique_sku(Product, external_sku, external_id):
|
||||
raw = re.sub(
|
||||
r"\s+",
|
||||
"-",
|
||||
str(external_sku or f"moysklad-{external_id}").strip(),
|
||||
)[:128]
|
||||
if not Product.objects.filter(sku__iexact=raw).exists():
|
||||
return raw
|
||||
suffix = external_id.replace("-", "")[:10]
|
||||
base = raw[: max(1, 117 - len(suffix))]
|
||||
candidate = f"{base}-ms-{suffix}"[:128]
|
||||
sequence = 2
|
||||
while Product.objects.filter(sku__iexact=candidate).exists():
|
||||
tail = f"-{sequence}"
|
||||
candidate = f"{base[: 128 - len(tail)]}{tail}"
|
||||
sequence += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def create_historical_products(apps, schema_editor):
|
||||
SourceSystem = apps.get_model("commerce", "SourceSystem")
|
||||
Product = apps.get_model("commerce", "Product")
|
||||
ProductBinding = apps.get_model("commerce", "ProductBinding")
|
||||
SaleLine = apps.get_model("commerce", "SaleLine")
|
||||
|
||||
source = SourceSystem.objects.filter(code="moysklad").first()
|
||||
if source is None:
|
||||
return
|
||||
|
||||
missing_external_ids = list(
|
||||
SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
product__isnull=True,
|
||||
)
|
||||
.exclude(external_product_id="")
|
||||
.values_list("external_product_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for external_id in missing_external_ids:
|
||||
binding = ProductBinding.objects.filter(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
).first()
|
||||
if binding is None:
|
||||
sample = SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
external_product_id=external_id,
|
||||
).first()
|
||||
product = Product.objects.create(
|
||||
sku=_unique_sku(Product, sample.external_sku, external_id),
|
||||
name=str(sample.name or sample.external_sku or external_id)[:255],
|
||||
kind="product",
|
||||
is_active=False,
|
||||
)
|
||||
binding = ProductBinding.objects.create(
|
||||
product=product,
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
external_sku=str(sample.external_sku or "")[:128],
|
||||
metadata={
|
||||
"provider": "moysklad",
|
||||
"historical_from_document": True,
|
||||
},
|
||||
is_primary=True,
|
||||
)
|
||||
SaleLine.objects.filter(
|
||||
sale__source=source,
|
||||
external_product_id=external_id,
|
||||
product__isnull=True,
|
||||
).update(product_id=binding.product_id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("commerce", "0007_recover_sale_line_matches"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_historical_products, migrations.RunPython.noop),
|
||||
]
|
||||
@ -1,5 +1,5 @@
|
||||
from .costs import Cost, CostType
|
||||
from .product import Product, ProductKind
|
||||
from .product import Product, ProductImage, ProductKind
|
||||
from .sales import Sale, SaleEventType, SaleLine
|
||||
from .source import (
|
||||
IntegrationConnection,
|
||||
@ -21,6 +21,7 @@ __all__ = [
|
||||
"IntegrationSyncRun",
|
||||
"Product",
|
||||
"ProductBinding",
|
||||
"ProductImage",
|
||||
"ProductKind",
|
||||
"Sale",
|
||||
"SaleEventType",
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
|
||||
def product_image_upload_to(instance, filename):
|
||||
return f"commerce/products/{instance.product_id}/original/{Path(filename).name}"
|
||||
|
||||
|
||||
def product_thumbnail_upload_to(instance, filename):
|
||||
return f"commerce/products/{instance.product_id}/thumbnails/{Path(filename).name}"
|
||||
|
||||
|
||||
class ProductKind(models.TextChoices):
|
||||
PRODUCT = "product", "Товар"
|
||||
SERVICE = "service", "Услуга"
|
||||
@ -58,3 +67,65 @@ class Product(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sku} — {self.name}"
|
||||
|
||||
|
||||
class ProductImage(models.Model):
|
||||
"""Locally stored image imported from an external product source."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="images",
|
||||
verbose_name="Товар",
|
||||
)
|
||||
source = models.ForeignKey(
|
||||
"commerce.SourceSystem",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="product_images",
|
||||
verbose_name="Источник",
|
||||
)
|
||||
external_id = models.CharField("Внешний ID", max_length=255)
|
||||
file = models.ImageField(
|
||||
"Оригинал",
|
||||
upload_to=product_image_upload_to,
|
||||
max_length=500,
|
||||
)
|
||||
thumbnail = models.ImageField(
|
||||
"Миниатюра",
|
||||
upload_to=product_thumbnail_upload_to,
|
||||
max_length=500,
|
||||
blank=True,
|
||||
)
|
||||
filename = models.CharField("Имя файла", max_length=255, blank=True)
|
||||
title = models.CharField("Название", max_length=255, blank=True)
|
||||
source_size = models.PositiveBigIntegerField("Размер в источнике", default=0)
|
||||
source_updated_at = models.DateTimeField(
|
||||
"Изменено в источнике",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
position = models.PositiveIntegerField("Позиция", default=0)
|
||||
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 = "Изображения товаров"
|
||||
ordering = ["position", "created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["product", "source", "external_id"],
|
||||
name="commerce_product_image_identity",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["product", "is_primary", "position"],
|
||||
name="commerce_product_image_order",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or self.filename or self.external_id
|
||||
|
||||
@ -84,6 +84,11 @@ class ProductBinding(models.Model):
|
||||
external_sku = models.CharField(
|
||||
"Артикул в источнике", max_length=128, blank=True, db_index=True
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
"Нормализованные данные источника",
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
is_primary = models.BooleanField("Основная привязка", default=False)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
@ -95,7 +100,7 @@ class ProductBinding(models.Model):
|
||||
models.UniqueConstraint(
|
||||
fields=["source", "external_id"],
|
||||
name="commerce_binding_source_external",
|
||||
)
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
@ -214,6 +219,9 @@ class IntegrationSyncRun(models.Model):
|
||||
created_products = models.PositiveIntegerField("Создано товаров", default=0)
|
||||
matched_products = models.PositiveIntegerField("Сопоставлено", default=0)
|
||||
stock_snapshots = models.PositiveIntegerField("Снимков остатков", default=0)
|
||||
images = models.PositiveIntegerField("Изображений", default=0)
|
||||
image_errors = models.PositiveIntegerField("Ошибок изображений", default=0)
|
||||
sales = models.PositiveIntegerField("Продаж и возвратов", default=0)
|
||||
error = models.TextField("Ошибка", blank=True)
|
||||
started_at = models.DateTimeField("Начато", null=True, blank=True)
|
||||
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
|
||||
|
||||
73
backend/commerce/realtime.py
Normal file
73
backend/commerce/realtime.py
Normal file
@ -0,0 +1,73 @@
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from commerce.models import IntegrationConnection, IntegrationSyncRun
|
||||
|
||||
|
||||
COMMERCE_CONNECTIONS_GROUP = "commerce_connections"
|
||||
|
||||
|
||||
def _iso(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _run_payload(run: IntegrationSyncRun | None):
|
||||
if run is None:
|
||||
return None
|
||||
return {
|
||||
"id": str(run.pk),
|
||||
"connection": str(run.connection_id),
|
||||
"status": run.status,
|
||||
"task_id": run.task_id,
|
||||
"processed": run.processed,
|
||||
"created_products": run.created_products,
|
||||
"matched_products": run.matched_products,
|
||||
"stock_snapshots": run.stock_snapshots,
|
||||
"images": run.images,
|
||||
"image_errors": run.image_errors,
|
||||
"sales": run.sales,
|
||||
"error": run.error,
|
||||
"started_at": _iso(run.started_at),
|
||||
"finished_at": _iso(run.finished_at),
|
||||
"created_at": _iso(run.created_at),
|
||||
}
|
||||
|
||||
|
||||
def build_connection_update(
|
||||
connection: IntegrationConnection,
|
||||
*,
|
||||
run: IntegrationSyncRun | None = None,
|
||||
):
|
||||
return {
|
||||
"id": str(connection.pk),
|
||||
"status": connection.status,
|
||||
"account_name": connection.account_name,
|
||||
"last_error": connection.last_error,
|
||||
"last_tested_at": _iso(connection.last_tested_at),
|
||||
"last_sync_started_at": _iso(connection.last_sync_started_at),
|
||||
"last_sync_finished_at": _iso(connection.last_sync_finished_at),
|
||||
"last_stats": connection.last_stats,
|
||||
"task_id": connection.task_id,
|
||||
"auto_sync": connection.auto_sync,
|
||||
"sync_interval_minutes": connection.sync_interval_minutes,
|
||||
"next_sync_at": _iso(connection.next_sync_at),
|
||||
"updated_at": _iso(connection.updated_at),
|
||||
"recent_run": _run_payload(run),
|
||||
}
|
||||
|
||||
|
||||
def send_connection_update(
|
||||
connection: IntegrationConnection,
|
||||
*,
|
||||
run: IntegrationSyncRun | None = None,
|
||||
):
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
return
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
COMMERCE_CONNECTIONS_GROUP,
|
||||
{
|
||||
"type": "commerce_connection_updated",
|
||||
"connection": build_connection_update(connection, run=run),
|
||||
},
|
||||
)
|
||||
@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
@ -62,6 +63,13 @@ def _sale_metric_rows(
|
||||
gross=Coalesce(Sum(signed_gross), ZERO_MONEY),
|
||||
discounts=Coalesce(Sum(signed_discount), ZERO_MONEY),
|
||||
cost_of_goods=Coalesce(Sum(signed_cost), ZERO_MONEY),
|
||||
missing_cost_lines=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
sale__source__code="moysklad",
|
||||
metadata__cost_source="unavailable",
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(*group_fields)
|
||||
)
|
||||
@ -161,6 +169,8 @@ def _channel_metrics(
|
||||
"additional_costs": additional_costs,
|
||||
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
|
||||
"cost_breakdown": expense_breakdown,
|
||||
"missing_cost_lines": sale.get("missing_cost_lines", 0),
|
||||
"cost_complete": sale.get("missing_cost_lines", 0) == 0,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@ -226,6 +236,8 @@ def get_product_metrics(
|
||||
"additional_costs": additional_costs,
|
||||
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
|
||||
"cost_breakdown": expense_breakdown,
|
||||
"missing_cost_lines": sale.get("missing_cost_lines", 0),
|
||||
"cost_complete": sale.get("missing_cost_lines", 0) == 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dateutil import parser as date_parser
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from commerce.integrations.moysklad import MoySkladClient
|
||||
from commerce.media import sync_moysklad_product_images
|
||||
from commerce.models import (
|
||||
IntegrationConnection,
|
||||
Product,
|
||||
@ -24,12 +27,17 @@ from commerce.services.ingestion import ingest_source_record
|
||||
from commerce.services.matching import match_product
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoySkladSyncStats:
|
||||
processed: int = 0
|
||||
created_products: int = 0
|
||||
matched_products: int = 0
|
||||
stock_snapshots: int = 0
|
||||
images: int = 0
|
||||
image_errors: int = 0
|
||||
sales: int = 0
|
||||
sale_lines: int = 0
|
||||
|
||||
@ -42,7 +50,7 @@ def _external_id(row: dict[str, Any]) -> str:
|
||||
if value:
|
||||
return value
|
||||
href = str(row.get("meta", {}).get("href") or "").rstrip("/")
|
||||
return href.rsplit("/", 1)[-1]
|
||||
return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _barcode(row: dict[str, Any]) -> str:
|
||||
@ -89,6 +97,98 @@ def _product_kind(row: dict[str, Any]) -> str:
|
||||
return ProductKind.PRODUCT
|
||||
|
||||
|
||||
def _reference_external_id(value: Any) -> str:
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
direct = str(value.get("id") or "").strip()
|
||||
if direct:
|
||||
return direct
|
||||
meta = value.get("meta") if isinstance(value.get("meta"), dict) else {}
|
||||
href = str(meta.get("href") or "").rstrip("/")
|
||||
return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _reference_name(value: Any) -> str:
|
||||
return str(value.get("name") or "") if isinstance(value, dict) else ""
|
||||
|
||||
|
||||
def _price_value(value: Any) -> str | None:
|
||||
if not isinstance(value, dict) or "value" not in value:
|
||||
return None
|
||||
return str(_money(value.get("value")))
|
||||
|
||||
|
||||
def _binding_metadata(row: dict[str, Any]) -> dict[str, Any]:
|
||||
sale_prices = []
|
||||
for price in row.get("salePrices") or []:
|
||||
if not isinstance(price, dict):
|
||||
continue
|
||||
price_type = (
|
||||
price.get("priceType") if isinstance(price.get("priceType"), dict) else {}
|
||||
)
|
||||
sale_prices.append(
|
||||
{
|
||||
"name": str(price_type.get("name") or ""),
|
||||
"external_id": _reference_external_id(price_type),
|
||||
"value": str(_money(price.get("value"))),
|
||||
}
|
||||
)
|
||||
attributes = []
|
||||
for attribute in row.get("attributes") or []:
|
||||
if isinstance(attribute, dict):
|
||||
attributes.append(
|
||||
{
|
||||
"external_id": str(attribute.get("id") or ""),
|
||||
"name": str(attribute.get("name") or ""),
|
||||
"type": str(attribute.get("type") or ""),
|
||||
"value": attribute.get("value"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"provider": "moysklad",
|
||||
"entity_type": str((row.get("meta") or {}).get("type") or ""),
|
||||
"external_code": str(row.get("externalCode") or ""),
|
||||
"parent_external_id": _reference_external_id(row.get("product")),
|
||||
"purchase_price": _price_value(row.get("buyPrice")),
|
||||
"minimum_price": _price_value(row.get("minPrice")),
|
||||
"sale_prices": sale_prices,
|
||||
"uom": {
|
||||
"external_id": _reference_external_id(row.get("uom")),
|
||||
"name": _reference_name(row.get("uom")),
|
||||
},
|
||||
"folder": {
|
||||
"external_id": _reference_external_id(row.get("productFolder")),
|
||||
"name": str(row.get("pathName") or ""),
|
||||
},
|
||||
"weight": row.get("weight"),
|
||||
"volume": row.get("volume"),
|
||||
"vat": row.get("effectiveVat", row.get("vat")),
|
||||
"attributes": attributes,
|
||||
"characteristics": row.get("characteristics") or [],
|
||||
"source_updated_at": str(row.get("updated") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _update_product_from_source(product: Product, row: dict[str, Any]) -> None:
|
||||
external_id = _external_id(row)
|
||||
external_sku = str(row.get("article") or row.get("code") or "").strip()
|
||||
product.name = str(row.get("name") or external_sku or external_id)[:255]
|
||||
product.barcode = _barcode(row)[:64]
|
||||
product.description = str(row.get("description") or "")
|
||||
product.kind = _product_kind(row)
|
||||
product.is_active = not bool(row.get("archived"))
|
||||
product.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
"barcode",
|
||||
"description",
|
||||
"kind",
|
||||
"is_active",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _candidate_sku(row: dict[str, Any], external_id: str) -> str:
|
||||
raw = str(row.get("article") or row.get("code") or "").strip()
|
||||
if not raw:
|
||||
@ -125,15 +225,26 @@ def normalize_moysklad_assortment_row(
|
||||
source_updated_at=_source_updated_at(row),
|
||||
)
|
||||
|
||||
match = match_product(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
external_sku=external_sku,
|
||||
barcode=barcode,
|
||||
existing_binding = (
|
||||
ProductBinding.objects.select_related("product")
|
||||
.filter(source=source, external_id=external_id)
|
||||
.first()
|
||||
)
|
||||
product = existing_binding.product if existing_binding else None
|
||||
if product is None and str((row.get("meta") or {}).get("type") or "") != "variant":
|
||||
candidate = match_product(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
external_sku=external_sku,
|
||||
barcode=barcode,
|
||||
)
|
||||
if candidate.product and not ProductBinding.objects.filter(
|
||||
product=candidate.product,
|
||||
source=source,
|
||||
).exists():
|
||||
product = candidate.product
|
||||
created = False
|
||||
matched = match.matched
|
||||
product = match.product
|
||||
matched = product is not None
|
||||
if product is None:
|
||||
product = Product.objects.create(
|
||||
sku=_candidate_sku(row, external_id),
|
||||
@ -145,35 +256,89 @@ def normalize_moysklad_assortment_row(
|
||||
)
|
||||
created = True
|
||||
|
||||
if created or not product.bindings.exclude(source=source).exists():
|
||||
_update_product_from_source(product, row)
|
||||
|
||||
binding, _ = ProductBinding.objects.update_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
defaults={
|
||||
"product": product,
|
||||
"external_sku": external_sku[:128],
|
||||
"metadata": _binding_metadata(row),
|
||||
"is_primary": True,
|
||||
},
|
||||
)
|
||||
|
||||
has_stock = any(key in row for key in ("stock", "reserve", "inTransit"))
|
||||
if has_stock and product.kind != ProductKind.SERVICE:
|
||||
StockSnapshot.objects.update_or_create(
|
||||
product=product,
|
||||
parent_external_id = str(binding.metadata.get("parent_external_id") or "")
|
||||
if parent_external_id:
|
||||
parent_binding = ProductBinding.objects.filter(
|
||||
source=source,
|
||||
warehouse_external_id="all",
|
||||
external_id=parent_external_id,
|
||||
).first()
|
||||
if parent_binding and parent_binding.product_id != product.pk:
|
||||
product.parent_id = parent_binding.product_id
|
||||
product.save(update_fields=["parent", "updated_at"])
|
||||
has_stock = any(key in row for key in ("stock", "reserve", "inTransit"))
|
||||
return created, matched, has_stock and product.kind != ProductKind.SERVICE
|
||||
|
||||
|
||||
def reconcile_moysklad_variant_parents(source) -> int:
|
||||
updated = 0
|
||||
bindings = {
|
||||
binding.external_id: binding
|
||||
for binding in ProductBinding.objects.filter(source=source).select_related(
|
||||
"product"
|
||||
)
|
||||
}
|
||||
for binding in bindings.values():
|
||||
parent_external_id = str(binding.metadata.get("parent_external_id") or "")
|
||||
parent_binding = bindings.get(parent_external_id)
|
||||
if not parent_binding or parent_binding.product_id == binding.product_id:
|
||||
continue
|
||||
if binding.product.parent_id != parent_binding.product_id:
|
||||
binding.product.parent_id = parent_binding.product_id
|
||||
binding.product.save(update_fields=["parent", "updated_at"])
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def normalize_moysklad_stock_row(
|
||||
*, connection: IntegrationConnection, row: dict[str, Any], captured_at: datetime
|
||||
) -> int:
|
||||
source = connection.source
|
||||
external_product_id = _external_id(row)
|
||||
binding = ProductBinding.objects.filter(
|
||||
source=source,
|
||||
external_id=external_product_id,
|
||||
).first()
|
||||
if not binding:
|
||||
return 0
|
||||
created = 0
|
||||
for store_stock in row.get("stockByStore") or []:
|
||||
if not isinstance(store_stock, dict):
|
||||
continue
|
||||
warehouse_external_id = _reference_external_id(store_stock)
|
||||
if not warehouse_external_id:
|
||||
continue
|
||||
StockSnapshot.objects.update_or_create(
|
||||
product=binding.product,
|
||||
source=source,
|
||||
warehouse_external_id=warehouse_external_id,
|
||||
captured_at=captured_at,
|
||||
defaults={
|
||||
"warehouse_name": "Все склады",
|
||||
"quantity": _decimal(row.get("stock")),
|
||||
"reserved": _decimal(row.get("reserve")),
|
||||
"in_transit": _decimal(row.get("inTransit")),
|
||||
"warehouse_name": str(store_stock.get("name") or "")[:255],
|
||||
"quantity": _decimal(store_stock.get("stock")),
|
||||
"reserved": _decimal(store_stock.get("reserve")),
|
||||
"in_transit": _decimal(store_stock.get("inTransit")),
|
||||
"metadata": {
|
||||
"provider": "moysklad",
|
||||
"external_product_id": binding.external_id,
|
||||
"external_product_id": external_product_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
return created, matched, has_stock and product.kind != ProductKind.SERVICE
|
||||
created += 1
|
||||
return created
|
||||
|
||||
|
||||
def _document_positions(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
@ -187,11 +352,86 @@ def _assortment_external_id(position: dict[str, Any]) -> str:
|
||||
assortment = position.get("assortment") or {}
|
||||
if not isinstance(assortment, dict):
|
||||
return ""
|
||||
value = str(assortment.get("id") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
href = str(assortment.get("meta", {}).get("href") or "").rstrip("/")
|
||||
return href.rsplit("/", 1)[-1]
|
||||
return _reference_external_id(assortment)
|
||||
|
||||
|
||||
def _document_currency(row: dict[str, Any]) -> str:
|
||||
rate = row.get("rate") if isinstance(row.get("rate"), dict) else {}
|
||||
currency = (
|
||||
rate.get("currency") if isinstance(rate.get("currency"), dict) else {}
|
||||
)
|
||||
value = str(
|
||||
currency.get("isoCode")
|
||||
or currency.get("code")
|
||||
or currency.get("name")
|
||||
or "RUB"
|
||||
).upper()
|
||||
return value[:3] if len(value) >= 3 else "RUB"
|
||||
|
||||
|
||||
def _document_channel(
|
||||
row: dict[str, Any], channel_names: dict[str, str] | None
|
||||
) -> str:
|
||||
sales_channel = (
|
||||
row.get("salesChannel") if isinstance(row.get("salesChannel"), dict) else {}
|
||||
)
|
||||
name = str(sales_channel.get("name") or "").strip()
|
||||
if not name and channel_names:
|
||||
name = channel_names.get(_reference_external_id(sales_channel), "")
|
||||
return (name or "Не указан")[:64]
|
||||
|
||||
|
||||
def _position_cost(position: dict[str, Any], quantity: Decimal) -> tuple[Decimal, str]:
|
||||
position_cost = _money(position.get("cost"))
|
||||
if position_cost > 0:
|
||||
return (quantity * position_cost).quantize(Decimal("0.01")), "position.cost"
|
||||
assortment = (
|
||||
position.get("assortment")
|
||||
if isinstance(position.get("assortment"), dict)
|
||||
else {}
|
||||
)
|
||||
buy_price = assortment.get("buyPrice")
|
||||
unit_cost = (
|
||||
_money(buy_price.get("value"))
|
||||
if isinstance(buy_price, dict)
|
||||
else Decimal("0")
|
||||
)
|
||||
return (quantity * unit_cost).quantize(Decimal("0.01")), (
|
||||
"assortment.buyPrice" if unit_cost > 0 else "unavailable"
|
||||
)
|
||||
|
||||
|
||||
def _historical_product_from_document(
|
||||
*, source, assortment: dict[str, Any], external_id: str
|
||||
) -> Product:
|
||||
existing = ProductBinding.objects.select_related("product").filter(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
).first()
|
||||
if existing:
|
||||
return existing.product
|
||||
external_sku = str(
|
||||
assortment.get("article") or assortment.get("code") or ""
|
||||
).strip()
|
||||
product = Product.objects.create(
|
||||
sku=_candidate_sku(assortment, external_id),
|
||||
name=str(assortment.get("name") or external_sku or external_id)[:255],
|
||||
barcode=_barcode(assortment)[:64],
|
||||
description=str(assortment.get("description") or ""),
|
||||
kind=_product_kind(assortment),
|
||||
is_active=False,
|
||||
)
|
||||
metadata = _binding_metadata(assortment)
|
||||
metadata["historical_from_document"] = True
|
||||
ProductBinding.objects.create(
|
||||
product=product,
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
external_sku=external_sku[:128],
|
||||
metadata=metadata,
|
||||
is_primary=True,
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@ -200,6 +440,7 @@ def normalize_moysklad_document(
|
||||
connection: IntegrationConnection,
|
||||
row: dict[str, Any],
|
||||
document_type: str,
|
||||
channel_names: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
source = connection.source
|
||||
source_id = _external_id(row)
|
||||
@ -218,9 +459,6 @@ def normalize_moysklad_document(
|
||||
)
|
||||
occurred_at = _source_updated_at({"updated": row.get("moment")}) or timezone.now()
|
||||
state = row.get("state") if isinstance(row.get("state"), dict) else {}
|
||||
sales_channel = (
|
||||
row.get("salesChannel") if isinstance(row.get("salesChannel"), dict) else {}
|
||||
)
|
||||
sale, _ = Sale.objects.update_or_create(
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
@ -228,10 +466,10 @@ def normalize_moysklad_document(
|
||||
"source_record": source_result.record,
|
||||
"event_type": event_type,
|
||||
"number": str(row.get("name") or "")[:128],
|
||||
"channel": str(sales_channel.get("name") or "moysklad")[:64],
|
||||
"channel": _document_channel(row, channel_names),
|
||||
"status": str(state.get("name") or "")[:128],
|
||||
"counts_as_sale": bool(row.get("applicable", True)),
|
||||
"currency": "RUB",
|
||||
"currency": _document_currency(row),
|
||||
"occurred_at": occurred_at,
|
||||
"source_updated_at": _source_updated_at(row),
|
||||
},
|
||||
@ -253,23 +491,37 @@ def normalize_moysklad_document(
|
||||
external_sku=external_sku,
|
||||
barcode=barcode,
|
||||
)
|
||||
matched_product = match.product
|
||||
if matched_product is None and product_external_id:
|
||||
matched_product = _historical_product_from_document(
|
||||
source=source,
|
||||
assortment=assortment,
|
||||
external_id=product_external_id,
|
||||
)
|
||||
quantity = _decimal(position.get("quantity"))
|
||||
gross = (quantity * _money(position.get("price"))).quantize(Decimal("0.01"))
|
||||
discount_percent = _decimal(position.get("discount"))
|
||||
discount = (gross * discount_percent / Decimal("100")).quantize(Decimal("0.01"))
|
||||
cost_amount, cost_source = _position_cost(position, quantity)
|
||||
line, _ = SaleLine.objects.update_or_create(
|
||||
sale=sale,
|
||||
external_id=position_id,
|
||||
defaults={
|
||||
"product": match.product,
|
||||
"product": matched_product,
|
||||
"external_product_id": product_external_id[:255],
|
||||
"external_sku": external_sku[:128],
|
||||
"name": str(assortment.get("name") or "")[:255],
|
||||
"quantity": quantity,
|
||||
"gross_amount": gross,
|
||||
"discount_amount": min(discount, gross),
|
||||
"cost_amount": Decimal("0"),
|
||||
"metadata": {"provider": "moysklad"},
|
||||
"cost_amount": cost_amount,
|
||||
"metadata": {
|
||||
"provider": "moysklad",
|
||||
"cost_source": cost_source,
|
||||
"vat": position.get("vat"),
|
||||
"vat_enabled": bool(position.get("vatEnabled")),
|
||||
"overhead": str(_money(position.get("overhead"))),
|
||||
},
|
||||
},
|
||||
)
|
||||
retained_line_ids.append(line.pk)
|
||||
@ -277,7 +529,11 @@ def normalize_moysklad_document(
|
||||
return len(retained_line_ids)
|
||||
|
||||
|
||||
def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncStats:
|
||||
def sync_moysklad_connection(
|
||||
connection: IntegrationConnection,
|
||||
*,
|
||||
progress_callback: Callable[[MoySkladSyncStats], None] | None = None,
|
||||
) -> MoySkladSyncStats:
|
||||
credentials = decrypt_credentials(connection)
|
||||
stats = MoySkladSyncStats()
|
||||
captured_at = timezone.now()
|
||||
@ -285,7 +541,7 @@ def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncS
|
||||
auth_type=connection.auth_type, credentials=credentials
|
||||
) as client:
|
||||
for row in client.iter_assortment():
|
||||
created, matched, stock_created = normalize_moysklad_assortment_row(
|
||||
created, matched, _has_stock = normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=row,
|
||||
captured_at=captured_at,
|
||||
@ -293,15 +549,52 @@ def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncS
|
||||
stats.processed += 1
|
||||
stats.created_products += int(created)
|
||||
stats.matched_products += int(matched)
|
||||
stats.stock_snapshots += int(stock_created)
|
||||
try:
|
||||
stats.images += sync_moysklad_product_images(
|
||||
connection=connection,
|
||||
row=row,
|
||||
client=client,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - one image must not abort facts
|
||||
stats.image_errors += 1
|
||||
logger.warning(
|
||||
"MoySklad product images could not be synchronized",
|
||||
extra={
|
||||
"event": "commerce.product.images_sync_failed",
|
||||
"connection_id": str(connection.pk),
|
||||
"external_product_id": str(row.get("id") or ""),
|
||||
"entity_type": str((row.get("meta") or {}).get("type") or ""),
|
||||
"error_type": type(error).__name__,
|
||||
"failure_reason": str(error)[:500],
|
||||
},
|
||||
)
|
||||
if progress_callback and stats.processed % 25 == 0:
|
||||
progress_callback(stats)
|
||||
reconcile_moysklad_variant_parents(connection.source)
|
||||
for stock_row in client.iter_stock_by_store():
|
||||
stats.stock_snapshots += normalize_moysklad_stock_row(
|
||||
connection=connection,
|
||||
row=stock_row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
StockSnapshot.objects.filter(
|
||||
source=connection.source,
|
||||
warehouse_external_id="all",
|
||||
).delete()
|
||||
channel_names = client.sales_channel_names()
|
||||
for document_type in ("demand", "salesreturn"):
|
||||
for row in client.iter_documents(document_type):
|
||||
stats.sale_lines += normalize_moysklad_document(
|
||||
connection=connection,
|
||||
row=row,
|
||||
document_type=document_type,
|
||||
channel_names=channel_names,
|
||||
)
|
||||
stats.sales += 1
|
||||
if progress_callback and stats.sales % 25 == 0:
|
||||
progress_callback(stats)
|
||||
if progress_callback:
|
||||
progress_callback(stats)
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from commerce.models import Cost, Product, SaleLine, StockSnapshot
|
||||
from commerce.models import Cost, Product, ProductImage, SaleLine, StockSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -31,6 +31,7 @@ def merge_products(
|
||||
|
||||
for product in sources:
|
||||
product.bindings.update(product=locked_target)
|
||||
ProductImage.objects.filter(product=product).update(product=locked_target)
|
||||
SaleLine.objects.filter(product=product).update(product=locked_target)
|
||||
Cost.objects.filter(product=product).update(product=locked_target)
|
||||
for snapshot in StockSnapshot.objects.filter(product=product):
|
||||
|
||||
19
backend/commerce/signals.py
Normal file
19
backend/commerce/signals.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from commerce.models import ProductImage
|
||||
|
||||
|
||||
def _delete_file_after_commit(field_file):
|
||||
if not field_file or not field_file.name:
|
||||
return
|
||||
storage = field_file.storage
|
||||
name = field_file.name
|
||||
transaction.on_commit(lambda: storage.delete(name))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ProductImage)
|
||||
def delete_product_image_files(sender, instance, **kwargs):
|
||||
_delete_file_after_commit(instance.file)
|
||||
_delete_file_after_commit(instance.thumbnail)
|
||||
@ -9,6 +9,7 @@ from django.utils import timezone
|
||||
from commerce.integrations import sync_registered_source
|
||||
from commerce.integrations.moysklad import MoySkladError
|
||||
from commerce.models import IntegrationConnection, IntegrationStatus, IntegrationSyncRun
|
||||
from commerce.realtime import send_connection_update
|
||||
from commerce.services import sync_moysklad_connection
|
||||
from commerce.services.credentials import CredentialError
|
||||
|
||||
@ -42,6 +43,7 @@ def enqueue_connection_sync(connection):
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
send_connection_update(locked, run=run)
|
||||
try:
|
||||
async_result = sync_moysklad_run.delay(str(run.pk))
|
||||
except Exception:
|
||||
@ -56,6 +58,9 @@ def enqueue_connection_sync(connection):
|
||||
last_error="Не удалось поставить синхронизацию в очередь.",
|
||||
next_sync_at=retry_at,
|
||||
)
|
||||
connection.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
send_connection_update(connection, run=run)
|
||||
raise
|
||||
task_id = async_result.id or ""
|
||||
IntegrationSyncRun.objects.filter(pk=run.pk).update(task_id=task_id)
|
||||
@ -90,10 +95,40 @@ def sync_moysklad_run(self, run_id: str):
|
||||
connection.save(
|
||||
update_fields=["status", "last_sync_started_at", "last_error", "updated_at"]
|
||||
)
|
||||
send_connection_update(connection, run=run)
|
||||
|
||||
def report_progress(current_stats):
|
||||
now = timezone.now()
|
||||
IntegrationSyncRun.objects.filter(pk=run.pk).update(
|
||||
processed=current_stats.processed,
|
||||
created_products=current_stats.created_products,
|
||||
matched_products=current_stats.matched_products,
|
||||
stock_snapshots=current_stats.stock_snapshots,
|
||||
images=current_stats.images,
|
||||
image_errors=current_stats.image_errors,
|
||||
sales=current_stats.sales,
|
||||
)
|
||||
IntegrationConnection.objects.filter(pk=connection.pk).update(updated_at=now)
|
||||
run.refresh_from_db()
|
||||
connection.refresh_from_db()
|
||||
send_connection_update(connection, run=run)
|
||||
|
||||
try:
|
||||
stats = sync_moysklad_connection(connection).to_dict()
|
||||
stats = sync_moysklad_connection(
|
||||
connection,
|
||||
progress_callback=report_progress,
|
||||
).to_dict()
|
||||
except Exception as exc:
|
||||
logger.exception("MoySklad synchronization failed", extra={"run_id": run_id})
|
||||
logger.exception(
|
||||
"MoySklad synchronization failed",
|
||||
extra={
|
||||
"event": "commerce.connection.sync_failed",
|
||||
"run_id": run_id,
|
||||
"connection_id": str(connection.pk),
|
||||
"provider": connection.provider,
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
if isinstance(exc, (MoySkladError, CredentialError)):
|
||||
message = str(exc)
|
||||
else:
|
||||
@ -116,6 +151,7 @@ def sync_moysklad_run(self, run_id: str):
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
send_connection_update(connection, run=run)
|
||||
raise
|
||||
|
||||
finished_at = timezone.now()
|
||||
@ -124,6 +160,9 @@ def sync_moysklad_run(self, run_id: str):
|
||||
run.created_products = stats["created_products"]
|
||||
run.matched_products = stats["matched_products"]
|
||||
run.stock_snapshots = stats["stock_snapshots"]
|
||||
run.images = stats["images"]
|
||||
run.image_errors = stats["image_errors"]
|
||||
run.sales = stats["sales"]
|
||||
run.finished_at = finished_at
|
||||
run.save(
|
||||
update_fields=[
|
||||
@ -132,6 +171,9 @@ def sync_moysklad_run(self, run_id: str):
|
||||
"created_products",
|
||||
"matched_products",
|
||||
"stock_snapshots",
|
||||
"images",
|
||||
"image_errors",
|
||||
"sales",
|
||||
"finished_at",
|
||||
]
|
||||
)
|
||||
@ -150,6 +192,7 @@ def sync_moysklad_run(self, run_id: str):
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
send_connection_update(connection, run=run)
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
asyncapi: 2.6.0
|
||||
info:
|
||||
title: EUSRR Realtime WebSocket API
|
||||
version: 1.3.0
|
||||
version: 1.4.0
|
||||
description: |
|
||||
Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR.
|
||||
|
||||
@ -38,6 +38,7 @@ channels:
|
||||
- typing indicators
|
||||
- реакции и голосования
|
||||
- procurement updates
|
||||
- статусы commerce-интеграций и фоновых синхронизаций
|
||||
- подписки на диапазоны календаря и calendar snapshots
|
||||
publish:
|
||||
operationId: clientAction
|
||||
@ -84,6 +85,7 @@ channels:
|
||||
- $ref: '#/components/messages/NotificationReadEvent'
|
||||
- $ref: '#/components/messages/NotificationsReadAllEvent'
|
||||
- $ref: '#/components/messages/ProcurementUpdateEvent'
|
||||
- $ref: '#/components/messages/CommerceConnectionUpdatedEvent'
|
||||
- $ref: '#/components/messages/TaskBoardUpdateEvent'
|
||||
- $ref: '#/components/messages/CalendarUpdateEvent'
|
||||
- $ref: '#/components/messages/CalendarSnapshotEvent'
|
||||
@ -212,6 +214,91 @@ components:
|
||||
is_recurring:
|
||||
type: boolean
|
||||
additionalProperties: true
|
||||
CommerceSyncRunPayload:
|
||||
type: object
|
||||
required: [id, connection, status, processed, created_products, matched_products, stock_snapshots, images, image_errors, sales]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
connection:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
task_id:
|
||||
type: string
|
||||
processed:
|
||||
type: integer
|
||||
created_products:
|
||||
type: integer
|
||||
matched_products:
|
||||
type: integer
|
||||
stock_snapshots:
|
||||
type: integer
|
||||
images:
|
||||
type: integer
|
||||
image_errors:
|
||||
type: integer
|
||||
sales:
|
||||
type: integer
|
||||
error:
|
||||
type: string
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
additionalProperties: false
|
||||
CommerceConnectionUpdatePayload:
|
||||
type: object
|
||||
required: [id, status, auto_sync, sync_interval_minutes, updated_at]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
account_name:
|
||||
type: string
|
||||
last_error:
|
||||
type: string
|
||||
last_tested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_sync_started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_sync_finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_stats:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
task_id:
|
||||
type: string
|
||||
auto_sync:
|
||||
type: boolean
|
||||
sync_interval_minutes:
|
||||
type: integer
|
||||
next_sync_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recent_run:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CommerceSyncRunPayload'
|
||||
- type: 'null'
|
||||
additionalProperties: false
|
||||
messages:
|
||||
OpenChatAction:
|
||||
name: OpenChatAction
|
||||
@ -726,6 +813,19 @@ components:
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CommerceConnectionUpdatedEvent:
|
||||
name: CommerceConnectionUpdatedEvent
|
||||
title: Изменение состояния commerce-интеграции
|
||||
payload:
|
||||
type: object
|
||||
required: [type, connection]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [commerce_connection_updated]
|
||||
connection:
|
||||
$ref: '#/components/schemas/CommerceConnectionUpdatePayload'
|
||||
additionalProperties: false
|
||||
TaskBoardUpdateEvent:
|
||||
name: TaskBoardUpdateEvent
|
||||
payload:
|
||||
|
||||
@ -329,6 +329,19 @@ LOGGING = {
|
||||
"level": os.getenv("LDAP_LOG_LEVEL", "WARNING").upper(),
|
||||
"propagate": False,
|
||||
},
|
||||
# Низкоуровневые стадии TCP/TLS и дублирующие access-записи внешних
|
||||
# HTTP-вызовов создают log storm. Бизнес-результат интеграции пишет
|
||||
# commerce, а транспорт сохраняем только при реальном предупреждении.
|
||||
"httpcore": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("HTTPCORE_LOG_LEVEL", "WARNING").upper(),
|
||||
"propagate": False,
|
||||
},
|
||||
"httpx": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("HTTPX_LOG_LEVEL", "WARNING").upper(),
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("CELERY_LOG_LEVEL", "INFO").upper(),
|
||||
|
||||
@ -24,6 +24,7 @@ from django.utils import timezone
|
||||
|
||||
from communications.consumers import ChatConsumerMixin
|
||||
from communications.services import ChatNotificationReadService
|
||||
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
|
||||
from scheduling.realtime import (
|
||||
MAX_CALENDAR_SUBSCRIPTIONS,
|
||||
CalendarSubscriptionError,
|
||||
@ -150,6 +151,13 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
notifications_channel = f"notifications_{self.user.id}"
|
||||
await self.channel_layer.group_add(notifications_channel, self.channel_name)
|
||||
|
||||
# Статусы интеграций не требуют отдельного сокета: все сотрудники уже
|
||||
# имеют read-доступ к commerce API, поэтому используем общий канал.
|
||||
await self.channel_layer.group_add(
|
||||
COMMERCE_CONNECTIONS_GROUP,
|
||||
self.channel_name,
|
||||
)
|
||||
|
||||
await self.accept()
|
||||
|
||||
# Запускаем ping цикл для keepalive
|
||||
@ -205,6 +213,10 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_discard(
|
||||
notifications_channel, self.channel_name
|
||||
)
|
||||
await self.channel_layer.group_discard(
|
||||
COMMERCE_CONNECTIONS_GROUP,
|
||||
self.channel_name,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Realtime consumer disconnected",
|
||||
@ -263,14 +275,15 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"WebSocket action received",
|
||||
extra={
|
||||
"event": "websocket.action.received",
|
||||
"action": action,
|
||||
"active_chat_id": self.active_chat_id,
|
||||
},
|
||||
)
|
||||
if action != "pong":
|
||||
logger.debug(
|
||||
"WebSocket action received",
|
||||
extra={
|
||||
"event": "websocket.action.received",
|
||||
"action": action,
|
||||
"active_chat_id": self.active_chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
handler_name = self.ACTION_HANDLERS.get(action)
|
||||
if handler_name is None:
|
||||
@ -540,6 +553,15 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
|
||||
}
|
||||
)
|
||||
|
||||
async def commerce_connection_updated(self, event):
|
||||
"""Передаёт изменение статуса интеграции через общий WebSocket."""
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "commerce_connection_updated",
|
||||
"connection": event.get("connection", {}),
|
||||
}
|
||||
)
|
||||
|
||||
# ==================== Обработка событий закупок ====================
|
||||
|
||||
async def poll_update(self, event):
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.core.files.base import ContentFile
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem, StockSnapshot
|
||||
from commerce.models import (
|
||||
Cost,
|
||||
Product,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
Sale,
|
||||
SaleLine,
|
||||
SourceSystem,
|
||||
StockSnapshot,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -46,6 +56,52 @@ def test_staff_can_create_product(api_client, user_factory):
|
||||
assert Product.objects.filter(sku="NEW").exists()
|
||||
|
||||
|
||||
def test_product_api_exposes_local_image_urls(api_client, user_factory):
|
||||
user = user_factory()
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
product = Product.objects.create(sku="PHOTO", name="Товар с фото")
|
||||
image = ProductImage(
|
||||
product=product,
|
||||
source=source,
|
||||
external_id="image-1",
|
||||
filename="photo.jpg",
|
||||
is_primary=True,
|
||||
)
|
||||
image.file.save("image-1.jpg", ContentFile(b"image"), save=False)
|
||||
image.thumbnail.save("image-1.webp", ContentFile(b"thumbnail"), save=False)
|
||||
image.save()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.get(reverse("api:v1:commerce:product-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
serialized = response.data["results"][0]["images"][0]
|
||||
assert serialized["is_primary"] is True
|
||||
assert serialized["url"].endswith(".jpg")
|
||||
assert serialized["thumbnail_url"].endswith(".webp")
|
||||
|
||||
|
||||
def test_deleting_source_in_use_returns_conflict(api_client, user_factory):
|
||||
user = user_factory(staff=True)
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
product = Product.objects.create(sku="MS-001", name="Товар")
|
||||
ProductBinding.objects.create(
|
||||
product=product,
|
||||
source=source,
|
||||
external_id="external-1",
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.delete(
|
||||
reverse("api:v1:commerce:source-detail", args=[source.pk])
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert response.data["code"] == "source_in_use"
|
||||
assert response.data["protected_count"] == 1
|
||||
assert SourceSystem.objects.filter(pk=source.pk).exists()
|
||||
|
||||
|
||||
def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory):
|
||||
user = user_factory(staff=True)
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
@ -109,6 +165,45 @@ def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory):
|
||||
assert StockSnapshot.objects.filter(pk=stock_response.data["id"]).exists()
|
||||
|
||||
|
||||
def test_management_searches_costs_and_stock_by_product_or_warehouse(
|
||||
api_client, user_factory
|
||||
):
|
||||
user = user_factory(staff=True)
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
product = Product.objects.create(sku="SEARCH-42", name="Товар для поиска")
|
||||
Cost.objects.create(
|
||||
source=source,
|
||||
product=product,
|
||||
external_id="cost-42",
|
||||
channel="marketplace",
|
||||
cost_type="commission",
|
||||
amount=Decimal("10"),
|
||||
occurred_at=timezone.now(),
|
||||
)
|
||||
StockSnapshot.objects.create(
|
||||
source=source,
|
||||
product=product,
|
||||
warehouse_external_id="warehouse-42",
|
||||
warehouse_name="Главный склад",
|
||||
quantity=Decimal("5"),
|
||||
captured_at=timezone.now(),
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
cost_response = api_client.get(
|
||||
reverse("api:v1:commerce:cost-list"), {"search": "SEARCH-42"}
|
||||
)
|
||||
stock_response = api_client.get(
|
||||
reverse("api:v1:commerce:stocksnapshot-list"),
|
||||
{"search": "Главный склад"},
|
||||
)
|
||||
|
||||
assert cost_response.status_code == status.HTTP_200_OK
|
||||
assert cost_response.data["count"] == 1
|
||||
assert stock_response.status_code == status.HTTP_200_OK
|
||||
assert stock_response.data["count"] == 1
|
||||
|
||||
|
||||
def test_product_analytics_endpoint(api_client, user_factory):
|
||||
user = user_factory()
|
||||
source = SourceSystem.objects.create(code="insales", name="InSales")
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
from datetime import timedelta
|
||||
import io
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
@ -12,21 +14,47 @@ from commerce.models import (
|
||||
IntegrationStatus,
|
||||
Product,
|
||||
ProductBinding,
|
||||
ProductImage,
|
||||
Sale,
|
||||
SourceRecord,
|
||||
SourceSystem,
|
||||
StockSnapshot,
|
||||
)
|
||||
from commerce.integrations.moysklad import MoySkladClient
|
||||
from commerce.integrations.moysklad import (
|
||||
MoySkladClient,
|
||||
MoySkladError,
|
||||
MoySkladRemoteImage,
|
||||
)
|
||||
from commerce.media import (
|
||||
_validated_image_and_thumbnail,
|
||||
sync_moysklad_product_images,
|
||||
)
|
||||
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
|
||||
from commerce.services.moysklad_sync import (
|
||||
MoySkladSyncStats,
|
||||
normalize_moysklad_assortment_row,
|
||||
normalize_moysklad_document,
|
||||
normalize_moysklad_stock_row,
|
||||
reconcile_moysklad_variant_parents,
|
||||
)
|
||||
from commerce.tasks import dispatch_due_connection_syncs
|
||||
from commerce.tasks import dispatch_due_connection_syncs, sync_moysklad_run
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _jpeg_bytes():
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (80, 60), (42, 133, 210)).save(output, format="JPEG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_product_thumbnail_rejects_decompression_bomb(monkeypatch):
|
||||
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
|
||||
|
||||
with pytest.raises((Image.DecompressionBombError, Image.DecompressionBombWarning)):
|
||||
_validated_image_and_thumbnail(_jpeg_bytes())
|
||||
|
||||
|
||||
def test_moysklad_client_keeps_api_prefix(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
requested_urls = []
|
||||
@ -49,6 +77,206 @@ def test_moysklad_client_keeps_api_prefix(settings):
|
||||
assert requested_urls == ["https://api.example/api/remap/1.2/context/employee"]
|
||||
|
||||
|
||||
def test_moysklad_client_accepts_token_with_bearer_prefix():
|
||||
client = MoySkladClient(
|
||||
auth_type="token",
|
||||
credentials={"token": " Bearer access-token-value "},
|
||||
)
|
||||
try:
|
||||
assert client.client.headers["Authorization"] == "Bearer access-token-value"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_moysklad_client_uses_vendor_accept_header():
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
try:
|
||||
assert client.client.headers["Accept"] == "application/json;charset=utf-8"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
image_content = _jpeg_bytes()
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/images"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"rows": [
|
||||
{
|
||||
"filename": "variant.jpg",
|
||||
"title": "Главное фото",
|
||||
"size": len(image_content),
|
||||
"updated": "2026-08-17 12:00:00.000",
|
||||
"meta": {
|
||||
"href": "https://api.example/api/remap/1.2/entity/variant/product-1/images/image-1",
|
||||
"downloadHref": "https://api.example/download/image-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=image_content,
|
||||
headers={"Content-Type": "image/jpeg"},
|
||||
request=request,
|
||||
)
|
||||
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
headers={"Authorization": "Bearer test"},
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
try:
|
||||
images = client.product_images("variant", "product-1")
|
||||
content, content_type = client.download_image(images[0].download_href)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert images[0].external_id == "image-1"
|
||||
assert images[0].filename == "variant.jpg"
|
||||
assert content == image_content
|
||||
assert content_type == "image/jpeg"
|
||||
|
||||
|
||||
def test_assortment_expands_images_in_supported_page_size(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
queries = []
|
||||
|
||||
def handler(request):
|
||||
queries.append(dict(request.url.params))
|
||||
return httpx.Response(200, json={"rows": []}, request=request)
|
||||
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
try:
|
||||
assert list(client.iter_assortment(limit=1000)) == []
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert queries == [
|
||||
{"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"}
|
||||
]
|
||||
|
||||
|
||||
def test_product_images_are_stored_deduplicated_and_removed_with_source_state():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
row = {
|
||||
"id": "external-product-1",
|
||||
"name": "Товар с фото",
|
||||
"article": "PHOTO-1",
|
||||
"meta": {"type": "variant"},
|
||||
"images": {"meta": {"size": 1}},
|
||||
}
|
||||
normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=row,
|
||||
captured_at=timezone.now(),
|
||||
)
|
||||
content = _jpeg_bytes()
|
||||
remote = MoySkladRemoteImage(
|
||||
external_id="image-1",
|
||||
filename="photo.jpg",
|
||||
title="Главное фото",
|
||||
size=len(content),
|
||||
updated="2026-08-17 12:00:00.000",
|
||||
download_href="https://api.example/download/image-1",
|
||||
)
|
||||
client = Mock()
|
||||
client.product_images.return_value = [remote]
|
||||
client.download_image.return_value = (content, "image/jpeg")
|
||||
|
||||
first_count = sync_moysklad_product_images(
|
||||
connection=connection,
|
||||
row=row,
|
||||
client=client,
|
||||
)
|
||||
second_count = sync_moysklad_product_images(
|
||||
connection=connection,
|
||||
row=row,
|
||||
client=client,
|
||||
)
|
||||
|
||||
image = ProductImage.objects.get(source=source, external_id="image-1")
|
||||
assert first_count == second_count == 1
|
||||
assert image.product.sku == "PHOTO-1"
|
||||
assert image.file.name.endswith(".jpg")
|
||||
assert image.thumbnail.name.endswith(".webp")
|
||||
assert image.is_primary is True
|
||||
assert client.download_image.call_count == 1
|
||||
|
||||
client.product_images.return_value = []
|
||||
assert sync_moysklad_product_images(
|
||||
connection=connection,
|
||||
row=row,
|
||||
client=client,
|
||||
) == 0
|
||||
assert not ProductImage.objects.filter(pk=image.pk).exists()
|
||||
|
||||
|
||||
def test_moysklad_client_includes_safe_upstream_error_detail(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={"errors": [{"error": "Некорректный токен доступа"}]},
|
||||
request=request,
|
||||
)
|
||||
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(MoySkladError, match="Некорректный токен доступа"):
|
||||
client.employee_context()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_connection_update_is_sent_to_shared_realtime_group():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
status=IntegrationStatus.SYNCING,
|
||||
)
|
||||
run = connection.sync_runs.create(status="running")
|
||||
sent = []
|
||||
|
||||
class ChannelLayer:
|
||||
async def group_send(self, group, event):
|
||||
sent.append((group, event))
|
||||
|
||||
with patch("commerce.realtime.get_channel_layer", return_value=ChannelLayer()):
|
||||
send_connection_update(connection, run=run)
|
||||
|
||||
assert sent[0][0] == COMMERCE_CONNECTIONS_GROUP
|
||||
assert sent[0][1]["type"] == "commerce_connection_updated"
|
||||
assert sent[0][1]["connection"]["status"] == IntegrationStatus.SYNCING
|
||||
assert sent[0][1]["connection"]["recent_run"]["id"] == str(run.pk)
|
||||
|
||||
|
||||
def test_staff_connects_moysklad_without_exposing_token(api_client, user_factory):
|
||||
user = user_factory(staff=True)
|
||||
api_client.force_authenticate(user=user)
|
||||
@ -117,6 +345,43 @@ def test_connection_sync_action_queues_background_run(api_client, user_factory):
|
||||
assert connection.task_id == "celery-task-1"
|
||||
|
||||
|
||||
def test_sync_run_persists_product_image_progress():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
run = connection.sync_runs.create()
|
||||
|
||||
def synchronize(_connection, *, progress_callback):
|
||||
stats = MoySkladSyncStats(
|
||||
processed=25,
|
||||
created_products=4,
|
||||
stock_snapshots=20,
|
||||
images=12,
|
||||
sales=3,
|
||||
)
|
||||
progress_callback(stats)
|
||||
return stats
|
||||
|
||||
with (
|
||||
patch("commerce.tasks.sync_moysklad_connection", side_effect=synchronize),
|
||||
patch("commerce.tasks.send_connection_update"),
|
||||
):
|
||||
result = sync_moysklad_run.run(str(run.pk))
|
||||
|
||||
run.refresh_from_db()
|
||||
connection.refresh_from_db()
|
||||
assert result["images"] == 12
|
||||
assert run.status == "success"
|
||||
assert run.processed == 25
|
||||
assert run.images == 12
|
||||
assert run.image_errors == 0
|
||||
assert run.sales == 3
|
||||
assert connection.last_stats["images"] == 12
|
||||
|
||||
|
||||
def test_beat_dispatcher_queues_due_connection():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
@ -164,7 +429,7 @@ def test_schedule_update_does_not_disconnect_connection(api_client, user_factory
|
||||
assert connection.next_sync_at is not None
|
||||
|
||||
|
||||
def test_moysklad_row_creates_product_binding_raw_record_and_stock():
|
||||
def test_moysklad_row_creates_product_binding_and_raw_record():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
@ -205,9 +470,61 @@ def test_moysklad_row_creates_product_binding_raw_record_and_stock():
|
||||
entity_type="product",
|
||||
external_id="external-product-1",
|
||||
).exists()
|
||||
snapshot = StockSnapshot.objects.get(product=product, source=source)
|
||||
assert snapshot.quantity == 12
|
||||
assert snapshot.reserved == 2
|
||||
assert not StockSnapshot.objects.filter(product=product, source=source).exists()
|
||||
|
||||
|
||||
def test_moysklad_stock_report_creates_separate_warehouse_snapshots():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
product = Product.objects.create(sku="STOCK-1", name="Товар")
|
||||
ProductBinding.objects.create(
|
||||
source=source,
|
||||
product=product,
|
||||
external_id="external-product-1",
|
||||
)
|
||||
captured_at = timezone.now()
|
||||
|
||||
created = normalize_moysklad_stock_row(
|
||||
connection=connection,
|
||||
captured_at=captured_at,
|
||||
row={
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/external-product-1?expand=supplier"
|
||||
},
|
||||
"stockByStore": [
|
||||
{
|
||||
"name": "Основной склад",
|
||||
"stock": 12,
|
||||
"reserve": 2,
|
||||
"inTransit": 3,
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-1"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Магазин",
|
||||
"stock": 4,
|
||||
"reserve": 1,
|
||||
"inTransit": 0,
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-2"
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert created == 2
|
||||
assert set(
|
||||
StockSnapshot.objects.values_list("warehouse_external_id", flat=True)
|
||||
) == {"store-1", "store-2"}
|
||||
first = StockSnapshot.objects.get(warehouse_external_id="store-1")
|
||||
assert first.quantity == 12
|
||||
assert first.reserved == 2
|
||||
|
||||
|
||||
def test_moysklad_row_matches_existing_product_by_sku():
|
||||
@ -241,6 +558,106 @@ def test_moysklad_row_matches_existing_product_by_sku():
|
||||
assert Product.objects.count() == 1
|
||||
|
||||
|
||||
def test_moysklad_variant_is_not_merged_with_parent_and_parent_is_reconciled():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
captured_at = timezone.now()
|
||||
variant_row = {
|
||||
"id": "variant-1",
|
||||
"name": "Футболка / XL",
|
||||
"article": "SHIRT-1",
|
||||
"meta": {"type": "variant"},
|
||||
"product": {
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/product-1"
|
||||
}
|
||||
},
|
||||
"characteristics": [{"name": "Размер", "value": "XL"}],
|
||||
}
|
||||
parent_row = {
|
||||
"id": "product-1",
|
||||
"name": "Футболка",
|
||||
"article": "SHIRT-1",
|
||||
"meta": {"type": "product"},
|
||||
}
|
||||
|
||||
normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=variant_row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=parent_row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
assert reconcile_moysklad_variant_parents(source) == 1
|
||||
|
||||
variant = ProductBinding.objects.get(
|
||||
source=source,
|
||||
external_id="variant-1",
|
||||
)
|
||||
parent = ProductBinding.objects.get(
|
||||
source=source,
|
||||
external_id="product-1",
|
||||
)
|
||||
assert variant.product != parent.product
|
||||
assert variant.product.parent == parent.product
|
||||
assert variant.metadata["characteristics"] == [
|
||||
{"name": "Размер", "value": "XL"}
|
||||
]
|
||||
|
||||
|
||||
def test_moysklad_existing_binding_refreshes_product_fields():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
captured_at = timezone.now()
|
||||
row = {
|
||||
"id": "product-1",
|
||||
"name": "Старое название",
|
||||
"article": "REFRESH-1",
|
||||
"meta": {"type": "product"},
|
||||
}
|
||||
normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"name": "Новое название",
|
||||
"description": "Новое описание",
|
||||
"barcodes": [{"ean13": "460000000002"}],
|
||||
"archived": True,
|
||||
}
|
||||
)
|
||||
|
||||
created, matched, _ = normalize_moysklad_assortment_row(
|
||||
connection=connection,
|
||||
row=row,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
product = ProductBinding.objects.get(
|
||||
source=source,
|
||||
external_id="product-1",
|
||||
).product
|
||||
assert created is False
|
||||
assert matched is True
|
||||
assert product.name == "Новое название"
|
||||
assert product.description == "Новое описание"
|
||||
assert product.barcode == "460000000002"
|
||||
assert product.is_active is False
|
||||
|
||||
|
||||
def test_moysklad_demand_creates_sale_and_matches_lines():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
@ -262,6 +679,11 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
|
||||
"updated": "2026-08-17 13:10:00.000",
|
||||
"applicable": True,
|
||||
"state": {"name": "Отгружен"},
|
||||
"salesChannel": {
|
||||
"meta": {
|
||||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/saleschannel/channel-1"
|
||||
}
|
||||
},
|
||||
"positions": {
|
||||
"rows": [
|
||||
{
|
||||
@ -273,6 +695,7 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
|
||||
"id": "external-product-1",
|
||||
"name": "Товар",
|
||||
"article": "SALE-001",
|
||||
"buyPrice": {"value": 7000},
|
||||
},
|
||||
}
|
||||
]
|
||||
@ -283,13 +706,64 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
|
||||
connection=connection,
|
||||
row=row,
|
||||
document_type="demand",
|
||||
channel_names={"channel-1": "robotail.pro"},
|
||||
)
|
||||
|
||||
assert line_count == 1
|
||||
sale = Sale.objects.get(source=source, external_id="demand:demand-1")
|
||||
assert sale.number == "00042"
|
||||
assert sale.status == "Отгружен"
|
||||
assert sale.channel == "robotail.pro"
|
||||
line = sale.lines.get()
|
||||
assert line.product == product
|
||||
assert line.gross_amount == 300
|
||||
assert line.discount_amount == 30
|
||||
assert line.cost_amount == 140
|
||||
assert line.metadata["cost_source"] == "assortment.buyPrice"
|
||||
|
||||
|
||||
def test_moysklad_document_retains_product_missing_from_current_assortment():
|
||||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||||
connection = IntegrationConnection.objects.create(
|
||||
source=source,
|
||||
provider="moysklad",
|
||||
encrypted_credentials="unused",
|
||||
)
|
||||
row = {
|
||||
"id": "demand-historical",
|
||||
"name": "00043",
|
||||
"moment": "2026-08-17 13:00:00.000",
|
||||
"positions": {
|
||||
"rows": [
|
||||
{
|
||||
"id": "position-historical",
|
||||
"quantity": 1,
|
||||
"price": 10000,
|
||||
"discount": 0,
|
||||
"assortment": {
|
||||
"id": "deleted-product-1",
|
||||
"name": "Удалённый товар",
|
||||
"article": "OLD-001",
|
||||
"meta": {"type": "product"},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
normalize_moysklad_document(
|
||||
connection=connection,
|
||||
row=row,
|
||||
document_type="demand",
|
||||
)
|
||||
|
||||
line = Sale.objects.get(external_id="demand:demand-historical").lines.get()
|
||||
assert line.product is not None
|
||||
assert line.product.name == "Удалённый товар"
|
||||
assert line.product.is_active is False
|
||||
binding = ProductBinding.objects.get(
|
||||
source=source,
|
||||
external_id="deleted-product-1",
|
||||
)
|
||||
assert binding.product == line.product
|
||||
assert binding.metadata["historical_from_document"] is True
|
||||
|
||||
@ -15,6 +15,7 @@ from channels.db import database_sync_to_async
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from communications.models import Chat, ChatMembership
|
||||
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
|
||||
from notifications.models import Notification
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
@ -114,6 +115,28 @@ class TestUserConsumerTaskBoardEvents:
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_receives_commerce_connection_update(ws_communicator, user):
|
||||
communicator = await ws_communicator(user=user)
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
await get_channel_layer().group_send(
|
||||
COMMERCE_CONNECTIONS_GROUP,
|
||||
{
|
||||
"type": "commerce_connection_updated",
|
||||
"connection": {"id": "connection-1", "status": "ready"},
|
||||
},
|
||||
)
|
||||
|
||||
response = await communicator.receive_json_from(timeout=5)
|
||||
assert response == {
|
||||
"type": "commerce_connection_updated",
|
||||
"connection": {"id": "connection-1", "status": "ready"},
|
||||
}
|
||||
await communicator.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerChatManagement:
|
||||
"""Тесты управления активным чатом."""
|
||||
|
||||
@ -86,6 +86,10 @@ def test_logging_settings_use_one_context_aware_console_pipeline():
|
||||
assert config["loggers"]["daphne"]["propagate"] is False
|
||||
assert config["loggers"]["django.channels.server"]["level"] == "WARNING"
|
||||
assert config["loggers"]["django.channels.server"]["propagate"] is False
|
||||
assert config["loggers"]["httpcore"]["level"] == "WARNING"
|
||||
assert config["loggers"]["httpcore"]["propagate"] is False
|
||||
assert config["loggers"]["httpx"]["level"] == "WARNING"
|
||||
assert config["loggers"]["httpx"]["propagate"] is False
|
||||
|
||||
|
||||
def test_context_filter_assigns_stable_event_to_framework_logs():
|
||||
|
||||
55
docs/guides/COMMERCE.md
Normal file
55
docs/guides/COMMERCE.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Commerce и МойСклад
|
||||
|
||||
Commerce-контур хранит нормализованные товары, внешние привязки, продажи,
|
||||
возвраты, расходы, остатки и локальные изображения товаров. Источником истины
|
||||
для структуры данных служат модели `backend/commerce/models/`, для HTTP API —
|
||||
`backend/api/v1/commerce/`, для импорта —
|
||||
`backend/commerce/services/moysklad_sync.py`.
|
||||
|
||||
## Импорт
|
||||
|
||||
Полная синхронизация запускается Celery-задачей
|
||||
`commerce.sync_moysklad_run`. Для работы ручного импорта нужен Celery worker,
|
||||
для расписания — также Celery Beat.
|
||||
|
||||
Импорт выполняет следующие этапы:
|
||||
|
||||
1. Загружает ассортимент страницами по 100 позиций с `expand=images`.
|
||||
2. Нормализует товары, модификации, комплекты, привязки и остатки.
|
||||
3. Скачивает оригиналы изображений по авторизованному `downloadHref`, проверяет
|
||||
формат и лимит 25 МБ, создаёт локальную WebP-миниатюру.
|
||||
4. Импортирует отгрузки и возвраты вместе с позициями.
|
||||
5. Сохраняет итоговую статистику и публикует состояние через общий WebSocket.
|
||||
|
||||
Прогресс запуска сохраняется и отправляется frontend каждые 25 товаров или
|
||||
документов. Ошибка отдельного изображения учитывается в `image_errors`, но не
|
||||
отменяет импорт товарных и финансовых данных.
|
||||
|
||||
## Изображения
|
||||
|
||||
`ProductImage` хранит оригинал и миниатюру в `MEDIA_ROOT/commerce/products/`.
|
||||
Временные внешние ссылки МойСклад не возвращаются клиенту и не используются как
|
||||
публичные URL. Повторная синхронизация сравнивает внешний размер и время
|
||||
изменения, поэтому неизменённое изображение не скачивается повторно. Удалённые
|
||||
в источнике изображения удаляются локально после успешного чтения списка.
|
||||
|
||||
REST `ProductSerializer` возвращает `images[]` с локальными `url` и
|
||||
`thumbnail_url`. Каталог использует миниатюру, карточка товара — оригинал для
|
||||
полноэкранного просмотра.
|
||||
|
||||
## Realtime и fallback
|
||||
|
||||
Событие `commerce_connection_updated` содержит состояние подключения и
|
||||
последнего запуска, включая `processed`, `images` и `sales`. При рабочем
|
||||
WebSocket frontend не опрашивает connections endpoint. Если WebSocket
|
||||
недоступен во время активного импорта, включается REST fallback раз в 30 секунд.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/commerce -q
|
||||
python manage.py makemigrations --check --dry-run
|
||||
python manage.py check
|
||||
ruff check commerce api/v1/commerce tests/commerce
|
||||
```
|
||||
@ -8,6 +8,7 @@
|
||||
- [LDAP.md](LDAP.md) — LDAP/AD authentication, write-back, sync, retry queue и admin.
|
||||
- [WEBSOCKET.md](WEBSOCKET.md) — единый `/ws/`, JWT, actions, events и production proxy.
|
||||
- [NOTIFICATIONS.md](NOTIFICATIONS.md) — создание, доставка, REST, realtime, email и Web Push.
|
||||
- [COMMERCE.md](COMMERCE.md) — каталог, МойСклад, изображения, Celery и realtime-прогресс.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ POST /api/auth/ws-ticket/ → ws://localhost:8000/ws/?ticket=ONE_TIME_TICKET
|
||||
- singleton frontend connection: `frontend/src/lib/websocketManager.ts`
|
||||
- chat adapter поверх singleton: `frontend/src/hooks/useWebSocket.ts`
|
||||
- calendar realtime publisher: `backend/scheduling/realtime.py`
|
||||
- commerce realtime publisher: `backend/commerce/realtime.py`
|
||||
- tests: `backend/tests/realtime/`
|
||||
|
||||
AsyncAPI доступен через `/api/ws/schema/` и `/api/ws/docs/`. При расхождении с этим guide ориентируйтесь на consumer и AsyncAPI schema.
|
||||
@ -56,6 +57,7 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
|
||||
- подписывается только на доступные активные `chat_{id}`;
|
||||
- подписывается на `user_{user_id}`;
|
||||
- подписывается на `notifications_{user_id}`;
|
||||
- подписывается на общий read-only канал статусов commerce-интеграций;
|
||||
- запускает server ping каждые 20 секунд.
|
||||
|
||||
Изменение `ChatMembership` синхронизируется с уже открытыми соединениями: при добавлении consumer входит в группу, при отзыве доступа немедленно выходит из неё и закрывает активный чат.
|
||||
@ -101,9 +103,15 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
|
||||
- typing: `typing_start`, `typing_stop`;
|
||||
- polls/read receipts: `poll_update`, `marked_read`;
|
||||
- notifications: `notification`, `unread_count`, `notification_read`, `notifications_read_all`;
|
||||
- domain events: `procurement_update`, `task_board_update`, `calendar_update`;
|
||||
- domain events: `procurement_update`, `task_board_update`, `calendar_update`, `commerce_connection_updated`;
|
||||
- calendar stream: `calendar_snapshot`, `calendar_error`.
|
||||
|
||||
`commerce_connection_updated` содержит безопасный срез состояния подключения и
|
||||
последнего запуска синхронизации без credentials. Экран управления применяет
|
||||
его к локальному состоянию вместо опроса `/api/v1/commerce/connections/` каждые
|
||||
2,5 секунды. При недоступном WebSocket остаётся REST fallback раз в 30 секунд;
|
||||
при живом соединении периодический запрос не выполняется.
|
||||
|
||||
`calendar_subscribe` открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет `calendar_snapshot` сразу после подписки и после связанных изменений. `scope=all` применяется только для staff/superuser.
|
||||
|
||||
`calendar_update` остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в `calendar_snapshot`; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами.
|
||||
|
||||
@ -1,20 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { CommerceProduct, ProductMetrics } from "@/types/commerce";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type {
|
||||
CommerceProduct,
|
||||
CommerceProductImage,
|
||||
ProductChannelMetrics,
|
||||
ProductMetrics,
|
||||
} from "@/types/commerce";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
GitMerge,
|
||||
Link2,
|
||||
Loader2,
|
||||
PackageCheck,
|
||||
Search,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@ -32,6 +44,31 @@ function formatMoney(value: string, currency: string) {
|
||||
}).format(Number(value));
|
||||
}
|
||||
|
||||
function cleanProductDescription(value: string) {
|
||||
return value
|
||||
.replace(/\.?https?:\/\/img\.youtube\.com\/\S+/gi, "")
|
||||
.replace(/https?:\/\/(?:www\.)?youtu(?:\.be|be\.com)\/\S+/gi, "")
|
||||
.replace(/\/\/www\.youtube\.com\/embed\/\S+/gi, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const kindLabels: Record<CommerceProduct["kind"], string> = {
|
||||
product: "Товар",
|
||||
service: "Услуга",
|
||||
bundle: "Комплект",
|
||||
};
|
||||
|
||||
const costTypeLabels: Record<string, string> = {
|
||||
commission: "Комиссия",
|
||||
delivery: "Доставка",
|
||||
storage: "Хранение",
|
||||
advertising: "Реклама",
|
||||
acquiring: "Эквайринг",
|
||||
return: "Возвраты",
|
||||
other: "Прочее",
|
||||
};
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const params = useParams<{ productId: string }>();
|
||||
const { user } = useUser();
|
||||
@ -45,6 +82,9 @@ export default function ProductDetailPage() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [mergeError, setMergeError] = useState<string | null>(null);
|
||||
const [previewImage, setPreviewImage] = useState<CommerceProductImage | null>(null);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [expandedChannelKey, setExpandedChannelKey] = useState<string | null>(null);
|
||||
const permissions = user?.auth?.permissions_by_app?.commerce ?? [];
|
||||
const canManage = Boolean(
|
||||
user?.auth?.is_staff ||
|
||||
@ -131,10 +171,10 @@ export default function ProductDetailPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="mx-auto w-full max-w-7xl space-y-5 p-4 sm:p-6">
|
||||
<main className="min-w-0 space-y-4 overflow-hidden">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
className="app-text-muted inline-flex items-center gap-2 text-sm hover:text-[var(--foreground)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> К каталогу
|
||||
</Link>
|
||||
@ -147,94 +187,178 @@ export default function ProductDetailPage() {
|
||||
</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>
|
||||
<header className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{product.images[0] ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewImage(product.images[0])}
|
||||
className="app-surface-muted relative aspect-[16/10] w-full overflow-hidden rounded-xl"
|
||||
aria-label="Открыть изображение товара"
|
||||
>
|
||||
<Image
|
||||
src={resolveMediaUrl(
|
||||
product.images[0].thumbnail_url || product.images[0].url,
|
||||
)}
|
||||
alt={product.images[0].title || product.name}
|
||||
fill
|
||||
loading="eager"
|
||||
sizes="(max-width: 1024px) 100vw, 520px"
|
||||
unoptimized
|
||||
className="object-contain p-3"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="app-badge rounded-full px-2.5 py-1 text-[10px] font-medium">
|
||||
{kindLabels[product.kind]}
|
||||
</span>
|
||||
<span className={`app-status-pill ${product.is_active ? "app-feedback-success" : "app-badge"}`}>
|
||||
{product.is_active ? "Активен" : "В архиве"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-text-muted app-text-wrap text-xs font-semibold uppercase tracking-[0.16em]">
|
||||
Артикул {product.sku || "—"}
|
||||
</p>
|
||||
<h1 className="app-text-wrap mt-1 max-w-full break-words text-xl font-semibold leading-tight text-[var(--foreground)] sm:text-2xl">
|
||||
{product.name}
|
||||
</h1>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMergeOpen(true)}
|
||||
className="app-action-secondary inline-flex w-fit items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<GitMerge className="h-4 w-4" /> Объединить карточки
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{product.images.length > 1 ? (
|
||||
<div className="mt-4 flex max-w-full gap-2 overflow-x-auto pb-1">
|
||||
{product.images.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
onClick={() => setPreviewImage(image)}
|
||||
className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg bg-[var(--surface-secondary)]"
|
||||
aria-label={`Открыть ${image.title || image.filename || "изображение"}`}
|
||||
>
|
||||
<Image
|
||||
src={resolveMediaUrl(image.thumbnail_url || image.url)}
|
||||
alt={image.title || product.name}
|
||||
fill
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canManage ? <button type="button" onClick={() => setMergeOpen(true)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><GitMerge className="h-4 w-4" /> Объединить</button> : null}
|
||||
<span className="app-badge rounded-full px-3 py-1 text-xs">
|
||||
{product.is_active ? "Активен" : "Архив"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-3">
|
||||
{product.description ? (
|
||||
<article className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<h2 className="font-semibold">Описание</h2>
|
||||
</div>
|
||||
<p
|
||||
className={`${descriptionExpanded ? "" : "line-clamp-6"} app-text-wrap max-w-full break-words text-sm leading-6 text-[var(--muted-foreground)]`}
|
||||
>
|
||||
{cleanProductDescription(product.description)}
|
||||
</p>
|
||||
{cleanProductDescription(product.description).length > 420 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((current) => !current)}
|
||||
className="app-action-ghost mt-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{descriptionExpanded ? (
|
||||
<><ChevronUp className="h-3.5 w-3.5" /> Свернуть</>
|
||||
) : (
|
||||
<><ChevronDown className="h-3.5 w-3.5" /> Показать полностью</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<section className="min-w-0 space-y-4">
|
||||
{metrics.monetary.length === 0 ? (
|
||||
<div className="app-surface rounded-xl p-5 lg:col-span-3">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
<div className="app-surface rounded-2xl p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="app-surface-muted inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<TrendingUp className="app-text-muted h-4 w-4" />
|
||||
</span>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Денежных операций по товару пока нет.
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
metrics.monetary.map((money) => (
|
||||
<article key={money.currency} className="app-surface rounded-xl p-5 lg:col-span-3">
|
||||
<article key={money.currency} className="app-surface min-w-0 rounded-2xl p-4">
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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 />
|
||||
<Metric label="Маржинальный доход" value={formatMoney(money.contribution_margin, money.currency)} accent wide />
|
||||
</div>
|
||||
{!money.cost_complete ? (
|
||||
<div className="app-feedback-warning mt-3 flex items-start gap-2 rounded-xl px-3 py-2.5 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Себестоимость неизвестна для {money.missing_cost_lines} позиций. Маржинальный доход показан как расчётный.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
<section className="min-w-0 space-y-4">
|
||||
<article className="app-surface min-w-0 rounded-2xl p-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h2 className="min-w-0 flex-1 font-semibold">Продажи по каналам</h2>
|
||||
<span className="app-counter inline-flex h-6 min-w-6 shrink-0 items-center justify-center px-2 text-[11px] font-bold">
|
||||
{metrics.by_channel.length}
|
||||
</span>
|
||||
</div>
|
||||
{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 className="space-y-2">
|
||||
{metrics.by_channel.map((channel) => {
|
||||
const channelKey = `${channel.channel}:${channel.currency}`;
|
||||
return (
|
||||
<ProductChannelCard
|
||||
key={channelKey}
|
||||
channel={channel}
|
||||
expanded={expandedChannelKey === channelKey}
|
||||
onToggle={() => setExpandedChannelKey((current) => current === channelKey ? null : channelKey)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="app-surface rounded-xl p-5">
|
||||
<article className="app-surface min-w-0 rounded-2xl p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Boxes className="h-4 w-4" />
|
||||
<PackageCheck className="h-4 w-4" />
|
||||
<h2 className="font-semibold">Остатки по источникам</h2>
|
||||
</div>
|
||||
{metrics.stock.length === 0 ? (
|
||||
@ -259,7 +383,7 @@ export default function ProductDetailPage() {
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="app-surface rounded-xl p-5">
|
||||
<article className="app-surface min-w-0 rounded-2xl p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<h2 className="font-semibold">Внешние соответствия</h2>
|
||||
@ -295,6 +419,17 @@ export default function ProductDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{previewImage ? (
|
||||
<MediaPreviewModal
|
||||
preview={{
|
||||
type: "image",
|
||||
src: resolveMediaUrl(previewImage.url),
|
||||
downloadUrl: resolveMediaUrl(previewImage.url),
|
||||
name: previewImage.title || previewImage.filename || product?.name || "Товар",
|
||||
}}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -303,17 +438,151 @@ function Metric({
|
||||
label,
|
||||
value,
|
||||
accent = false,
|
||||
wide = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: boolean;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="app-surface-muted rounded-lg p-3">
|
||||
<div className={`app-surface-muted min-w-0 rounded-lg p-3 ${wide ? "col-span-2" : ""}`}>
|
||||
<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)]"}`}>
|
||||
<p className={`app-text-wrap mt-1 break-words text-base font-semibold ${accent ? "text-emerald-600" : "text-[var(--foreground)]"}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductChannelCard({
|
||||
channel,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
channel: ProductChannelMetrics;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const detailsId = `product-channel-${channel.channel}-${channel.currency}`.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-",
|
||||
);
|
||||
const costBreakdown = Object.entries(channel.cost_breakdown);
|
||||
const marginIsPositive = Number(channel.contribution_margin) >= 0;
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<div className="flex min-w-0 items-start gap-3 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition"
|
||||
aria-label={expanded ? `Свернуть канал ${channel.channel}` : `Раскрыть канал ${channel.channel}`}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-start justify-between gap-3 text-left"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="app-text-wrap block break-words text-sm font-semibold text-[var(--foreground)]">
|
||||
{channel.channel}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
Количество: {formatDecimal(channel.quantity, 4)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="app-text-muted block text-[11px]">Выручка</span>
|
||||
<span className="mt-1 block text-sm font-semibold text-[var(--foreground)]">
|
||||
{formatMoney(channel.net_revenue, channel.currency)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div
|
||||
id={detailsId}
|
||||
className="app-surface-elevated mx-3 mb-3 rounded-xl p-3"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-3 text-xs sm:grid-cols-3">
|
||||
<ChannelDetail
|
||||
label="Себестоимость"
|
||||
value={formatMoney(channel.cost_of_goods, channel.currency)}
|
||||
/>
|
||||
<ChannelDetail
|
||||
label="Доп. расходы"
|
||||
value={formatMoney(channel.additional_costs, channel.currency)}
|
||||
/>
|
||||
<ChannelDetail
|
||||
label="Маржинальный доход"
|
||||
value={formatMoney(channel.contribution_margin, channel.currency)}
|
||||
valueClassName={marginIsPositive ? "text-emerald-500" : "text-[var(--danger-foreground)]"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{costBreakdown.length > 0 ? (
|
||||
<div className="mt-4 border-t border-[var(--border-subtle)] pt-3">
|
||||
<p className="app-text-muted mb-2 text-[11px] font-semibold uppercase tracking-wide">
|
||||
Структура дополнительных расходов
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.map(([costType, value]) => (
|
||||
<div
|
||||
key={costType}
|
||||
className="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<span className="app-text-muted">
|
||||
{costTypeLabels[costType] || costType}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--foreground)]">
|
||||
{formatMoney(value, channel.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!channel.cost_complete ? (
|
||||
<div className="app-feedback-warning mt-3 flex items-start gap-2 rounded-lg px-3 py-2 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Нет себестоимости для {channel.missing_cost_lines} позиций; маржа расчётная.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelDetail({
|
||||
label,
|
||||
value,
|
||||
valueClassName = "text-[var(--foreground)]",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="app-text-muted block">{label}</span>
|
||||
<span className={`app-text-wrap mt-1 block break-words font-semibold ${valueClassName}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,297 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
IntegrationConnection,
|
||||
IntegrationConnectionInput,
|
||||
} from "@/types/commerce";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
CloudDownload,
|
||||
Clock3,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const statusLabels: Record<IntegrationConnection["status"], string> = {
|
||||
disconnected: "Нужна проверка",
|
||||
ready: "Подключено",
|
||||
queued: "В очереди",
|
||||
syncing: "Импорт выполняется",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
function displayDate(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
import { ProductConnectionsContent } from "@/components/commerce/ProductConnectionsContent";
|
||||
|
||||
export default function ProductConnectionsPage() {
|
||||
const { user, loading: userLoading } = useUser();
|
||||
const [connections, setConnections] = useState<IntegrationConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [authType, setAuthType] = useState<"token" | "basic">("token");
|
||||
const [token, setToken] = useState("");
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const permissions = user?.auth?.permissions_by_app?.commerce ?? [];
|
||||
const canManage = Boolean(
|
||||
user?.auth?.is_staff ||
|
||||
user?.auth?.is_superuser ||
|
||||
permissions.some(
|
||||
(permission) =>
|
||||
permission.startsWith("add_") || permission.startsWith("change_"),
|
||||
),
|
||||
);
|
||||
|
||||
const moysklad = useMemo(
|
||||
() => connections.find((connection) => connection.provider === "moysklad"),
|
||||
[connections],
|
||||
);
|
||||
|
||||
const loadConnections = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.getCommerceConnections();
|
||||
setConnections(response.results);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось загрузить подключения.",
|
||||
);
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage) return;
|
||||
const timer = window.setTimeout(() => void loadConnections(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canManage, loadConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
const active = connections.some((connection) =>
|
||||
["queued", "syncing"].includes(connection.status),
|
||||
);
|
||||
if (!active) return;
|
||||
const timer = window.setTimeout(() => void loadConnections(true), 2500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [connections, loadConnections]);
|
||||
|
||||
async function connectAndImport(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy("connect");
|
||||
setError(null);
|
||||
const payload: IntegrationConnectionInput = {
|
||||
provider: "moysklad",
|
||||
auth_type: authType,
|
||||
...(authType === "token" ? { token } : { login, password }),
|
||||
};
|
||||
try {
|
||||
const connection = moysklad
|
||||
? await apiClient.updateCommerceConnection(moysklad.id, payload)
|
||||
: await apiClient.createCommerceConnection(payload);
|
||||
await apiClient.testCommerceConnection(connection.id);
|
||||
await apiClient.syncCommerceConnection(connection.id);
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setEditing(false);
|
||||
await loadConnections(true);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось подключить МойСклад.",
|
||||
);
|
||||
await loadConnections(true);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function startSync(connection: IntegrationConnection) {
|
||||
setBusy(connection.id);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.syncCommerceConnection(connection.id);
|
||||
await loadConnections(true);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось запустить импорт.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSchedule(
|
||||
connection: IntegrationConnection,
|
||||
changes: Pick<
|
||||
IntegrationConnectionInput,
|
||||
"auto_sync" | "sync_interval_minutes"
|
||||
>,
|
||||
) {
|
||||
setBusy(connection.id);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await apiClient.updateCommerceConnection(
|
||||
connection.id,
|
||||
changes,
|
||||
);
|
||||
setConnections((current) =>
|
||||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось изменить расписание.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(connection: IntegrationConnection) {
|
||||
if (!window.confirm("Отключить сервис? Уже импортированные товары сохранятся.")) {
|
||||
return;
|
||||
}
|
||||
setBusy(connection.id);
|
||||
try {
|
||||
await apiClient.deleteCommerceConnection(connection.id);
|
||||
setConnections((current) => current.filter((item) => item.id !== connection.id));
|
||||
setEditing(false);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось отключить сервис.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (userLoading) {
|
||||
return <AppShell><main className="p-6 text-sm app-text-muted">Проверка прав…</main></AppShell>;
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return <AppShell><main className="mx-auto max-w-3xl p-6"><div className="app-feedback-danger rounded-xl p-5 text-sm">У вас нет прав на подключение сервисов.</div></main></AppShell>;
|
||||
}
|
||||
|
||||
const syncActive = moysklad && ["queued", "syncing"].includes(moysklad.status);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="mx-auto w-full max-w-6xl space-y-5 p-4 sm:p-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/products" className="mb-2 inline-flex items-center gap-2 text-sm app-text-muted hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К товарам</Link>
|
||||
<h1 className="app-text-muted text-sm font-semibold uppercase tracking-wide">Подключения</h1>
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadConnections()} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><RefreshCw className="h-4 w-4" /> Обновить</button>
|
||||
</header>
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
|
||||
|
||||
<section className="app-surface rounded-2xl p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-sky-500"><Store className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="font-semibold">МойСклад</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm app-text-muted">Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.</p>
|
||||
</div>
|
||||
</div>
|
||||
{moysklad ? <span className={`rounded-full px-3 py-1 text-xs font-medium ${moysklad.status === "error" ? "bg-rose-500/10 text-rose-500" : syncActive ? "bg-sky-500/10 text-sky-500" : "bg-emerald-500/10 text-emerald-500"}`}>{syncActive ? <Loader2 className="mr-1 inline h-3.5 w-3.5 animate-spin" /> : null}{statusLabels[moysklad.status]}</span> : null}
|
||||
</div>
|
||||
|
||||
{loading ? <div className="mt-6 flex items-center gap-2 text-sm app-text-muted"><Loader2 className="h-4 w-4 animate-spin" /> Загрузка…</div> : moysklad && !editing ? (
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric icon={PackageCheck} label="Товаров обработано" value={moysklad.last_stats.processed ?? 0} />
|
||||
<Metric icon={CloudDownload} label="Создано товаров" value={moysklad.last_stats.created_products ?? 0} />
|
||||
<Metric icon={CheckCircle2} label="Сопоставлено" value={moysklad.last_stats.matched_products ?? 0} />
|
||||
<Metric icon={Store} label="Остатки" value={moysklad.last_stats.stock_snapshots ?? 0} />
|
||||
<Metric icon={CloudDownload} label="Продаж и возвратов" value={moysklad.last_stats.sales ?? 0} />
|
||||
</div>
|
||||
{moysklad.last_error ? <div className="app-feedback-danger flex gap-2 rounded-xl p-4 text-sm"><TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {moysklad.last_error}</div> : null}
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><dt className="text-xs app-text-muted">Учётная запись</dt><dd className="mt-1">{moysklad.account_name || "—"}</dd></div>
|
||||
<div><dt className="text-xs app-text-muted">Последняя проверка</dt><dd className="mt-1">{displayDate(moysklad.last_tested_at)}</dd></div>
|
||||
<div><dt className="text-xs app-text-muted">Последний импорт</dt><dd className="mt-1">{displayDate(moysklad.last_sync_finished_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-[var(--surface-secondary)] p-4">
|
||||
<label className="flex items-center gap-3 text-sm"><input type="checkbox" checked={moysklad.auto_sync} disabled={Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: event.target.checked, sync_interval_minutes: moysklad.sync_interval_minutes })} /><span><span className="block font-medium">Постоянная синхронизация</span><span className="block text-xs app-text-muted">Сервер запускает импорт автоматически</span></span></label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm app-text-muted"><Clock3 className="h-4 w-4" /><span>Каждые</span><select value={moysklad.sync_interval_minutes} disabled={!moysklad.auto_sync || Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: true, sync_interval_minutes: Number(event.target.value) })} className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-1.5 text-sm text-[var(--foreground)] disabled:opacity-50"><option value={5}>5 минут</option><option value={15}>15 минут</option><option value={30}>30 минут</option><option value={60}>1 час</option><option value={360}>6 часов</option><option value={1440}>сутки</option></select></label>
|
||||
<span className="text-xs app-text-muted">Следующий запуск: {moysklad.auto_sync ? displayDate(moysklad.next_sync_at) : "отключён"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void startSync(moysklad)} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50">{syncActive ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {syncActive ? "Импорт выполняется" : "Синхронизировать всё"}</button>
|
||||
<button type="button" disabled={Boolean(busy)} onClick={() => { setAuthType(moysklad.auth_type); setEditing(true); }} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><KeyRound className="h-4 w-4" /> Обновить доступ</button>
|
||||
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void disconnect(moysklad)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-rose-500 disabled:opacity-50"><Trash2 className="h-4 w-4" /> Отключить</button>
|
||||
</div>
|
||||
{moysklad.recent_runs.length ? <div><h3 className="mb-2 text-xs font-semibold uppercase tracking-wide app-text-muted">Последние запуски</h3><div className="divide-y divide-[var(--border-subtle)] rounded-xl border border-[var(--border-subtle)]">{moysklad.recent_runs.map((run) => <div key={run.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"><span>{displayDate(run.created_at)} · {run.status === "success" ? "завершено" : run.status === "error" ? "ошибка" : "выполняется"}</span><span className="app-text-muted">{run.processed} позиций</span></div>)}</div></div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={connectAndImport} className="mt-6 max-w-2xl space-y-4">
|
||||
<div className="rounded-xl bg-[var(--surface-secondary)] p-4 text-sm"><ShieldCheck className="mr-2 inline h-4 w-4 text-emerald-500" />Реквизиты шифруются на сервере и никогда не возвращаются в браузер.</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAuthType("token")} className={`rounded-lg px-3 py-2 text-sm ${authType === "token" ? "app-selected" : "app-action-secondary"}`}>API-токен</button>
|
||||
<button type="button" onClick={() => setAuthType("basic")} className={`rounded-lg px-3 py-2 text-sm ${authType === "basic" ? "app-selected" : "app-action-secondary"}`}>Логин и пароль</button>
|
||||
</div>
|
||||
{authType === "token" ? <Field label="Токен МойСклад" type="password" value={token} onChange={setToken} placeholder="Bearer-токен" /> : <div className="grid gap-4 sm:grid-cols-2"><Field label="Логин" value={login} onChange={setLogin} /><Field label="Пароль" type="password" value={password} onChange={setPassword} /></div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" disabled={busy === "connect"} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50">{busy === "connect" ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} Проверить и импортировать всё</button>
|
||||
{moysklad ? <button type="button" onClick={() => setEditing(false)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2">
|
||||
<PlannedService name="InSales" description="Каталог, варианты и заказы интернет-магазина" />
|
||||
<PlannedService name="RetailCRM" description="Заказы, каналы, статусы и расходы" />
|
||||
</section>
|
||||
</main>
|
||||
<ProductConnectionsContent />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, type = "text", placeholder }: { label: string; value: string; onChange: (value: string) => void; type?: string; placeholder?: string }) {
|
||||
return <label className="block"><span className="text-xs font-medium app-text-muted">{label}</span><input required type={type} autoComplete="off" value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className="mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]" /></label>;
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value }: { icon: typeof Store; label: string; value: number }) {
|
||||
return <div className="rounded-xl bg-[var(--surface-secondary)] p-3"><Icon className="mb-2 h-4 w-4 text-sky-500" /><p className="text-xl font-semibold">{new Intl.NumberFormat("ru-RU").format(value)}</p><p className="mt-0.5 text-xs app-text-muted">{label}</p></div>;
|
||||
}
|
||||
|
||||
function PlannedService({ name, description }: { name: string; description: string }) {
|
||||
return <div className="app-surface rounded-xl p-4 opacity-70"><div className="flex items-center justify-between gap-3"><div><h2 className="font-medium">{name}</h2><p className="mt-1 text-sm app-text-muted">{description}</p></div><span className="shrink-0 rounded-full bg-[var(--surface-secondary)] px-2.5 py-1 text-xs app-text-muted">Следующий этап</span></div></div>;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProductConnectionsContent } from "@/components/commerce/ProductConnectionsContent";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
@ -16,14 +17,18 @@ import type {
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
Database,
|
||||
Link2,
|
||||
ListTree,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
Plus,
|
||||
ReceiptText,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@ -35,12 +40,15 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Tab = "products" | "sources" | "bindings" | "sales" | "costs" | "stock";
|
||||
type Tab = "connections" | "products" | "sources" | "bindings" | "sales" | "costs" | "stock";
|
||||
type EditorKind = "product" | "source" | "binding" | "sale" | "line" | "cost" | "stock";
|
||||
type Editor = { kind: EditorKind; id?: string | number } | null;
|
||||
type FormState = Record<string, string | boolean>;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string; icon: typeof Boxes }> = [
|
||||
{ id: "connections", label: "Подключения", icon: PlugZap },
|
||||
{ id: "products", label: "Товары", icon: Boxes },
|
||||
{ id: "sources", label: "Источники", icon: Database },
|
||||
{ id: "bindings", label: "Привязки", icon: Link2 },
|
||||
@ -59,6 +67,12 @@ const costTypeLabels: Record<string, string> = {
|
||||
other: "Прочее",
|
||||
};
|
||||
|
||||
const productKindLabels: Record<CommerceProduct["kind"], string> = {
|
||||
product: "Товар",
|
||||
service: "Услуга",
|
||||
bundle: "Комплект",
|
||||
};
|
||||
|
||||
function localDateTime(value?: string | null) {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
@ -96,7 +110,13 @@ export default function CommerceManagementPage() {
|
||||
const [form, setForm] = useState<FormState>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [connectionsRefreshKey, setConnectionsRefreshKey] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [listCount, setListCount] = useState(0);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [hasPreviousPage, setHasPreviousPage] = useState(false);
|
||||
|
||||
const permissions = user?.auth?.permissions_by_app?.commerce ?? [];
|
||||
const canManage = Boolean(
|
||||
@ -111,32 +131,110 @@ export default function CommerceManagementPage() {
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (tab === "connections") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = search.trim() || undefined;
|
||||
const setPagination = (response: {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}) => {
|
||||
setListCount(response.count);
|
||||
setHasNextPage(Boolean(response.next));
|
||||
setHasPreviousPage(Boolean(response.previous));
|
||||
};
|
||||
|
||||
try {
|
||||
const [productData, sourceData, bindingData, saleData, costData, stockData] =
|
||||
await Promise.all([
|
||||
if (tab === "products") {
|
||||
const response = await apiClient.getCommerceProducts({
|
||||
search: query,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
ordering: "name",
|
||||
});
|
||||
setProducts(response.results);
|
||||
setPagination(response);
|
||||
} else if (tab === "sources") {
|
||||
const response = await apiClient.getCommerceSources({
|
||||
search: query || "",
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
setSources(response.results);
|
||||
setPagination(response);
|
||||
} else {
|
||||
const referenceRequests = Promise.all([
|
||||
apiClient.getCommerceProducts({ page_size: 200, ordering: "name" }),
|
||||
apiClient.getCommerceSources(),
|
||||
apiClient.getCommerceBindings({ page_size: 200 }),
|
||||
apiClient.getCommerceSales({ page_size: 200, ordering: "-occurred_at" }),
|
||||
apiClient.getCommerceCosts({ page_size: 200, ordering: "-occurred_at" }),
|
||||
apiClient.getCommerceStockSnapshots({
|
||||
page_size: 200,
|
||||
ordering: "-captured_at",
|
||||
}),
|
||||
]);
|
||||
setProducts(productData.results);
|
||||
setSources(sourceData.results);
|
||||
setBindings(bindingData.results);
|
||||
setSales(saleData.results);
|
||||
setCosts(costData.results);
|
||||
setStock(stockData.results);
|
||||
setSelectedSaleId((current) =>
|
||||
current && saleData.results.some((sale) => sale.id === current)
|
||||
? current
|
||||
: null,
|
||||
);
|
||||
|
||||
if (tab === "bindings") {
|
||||
const [response, [productData, sourceData]] = await Promise.all([
|
||||
apiClient.getCommerceBindings({
|
||||
search: query || "",
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}),
|
||||
referenceRequests,
|
||||
]);
|
||||
setBindings(response.results);
|
||||
setProducts(productData.results);
|
||||
setSources(sourceData.results);
|
||||
setPagination(response);
|
||||
} else if (tab === "sales") {
|
||||
const [response, [productData, sourceData]] = await Promise.all([
|
||||
apiClient.getCommerceSales({
|
||||
search: query || "",
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
ordering: "-occurred_at",
|
||||
}),
|
||||
referenceRequests,
|
||||
]);
|
||||
setSales(response.results);
|
||||
setProducts(productData.results);
|
||||
setSources(sourceData.results);
|
||||
setPagination(response);
|
||||
setSelectedSaleId((current) =>
|
||||
current && response.results.some((sale) => sale.id === current)
|
||||
? current
|
||||
: null,
|
||||
);
|
||||
} else if (tab === "costs") {
|
||||
const [response, [productData, sourceData]] = await Promise.all([
|
||||
apiClient.getCommerceCosts({
|
||||
search: query || "",
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
ordering: "-occurred_at",
|
||||
}),
|
||||
referenceRequests,
|
||||
]);
|
||||
setCosts(response.results);
|
||||
setProducts(productData.results);
|
||||
setSources(sourceData.results);
|
||||
setPagination(response);
|
||||
} else {
|
||||
const [response, [productData, sourceData]] = await Promise.all([
|
||||
apiClient.getCommerceStockSnapshots({
|
||||
search: query || "",
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
ordering: "-captured_at",
|
||||
}),
|
||||
referenceRequests,
|
||||
]);
|
||||
setStock(response.results);
|
||||
setProducts(productData.results);
|
||||
setSources(sourceData.results);
|
||||
setPagination(response);
|
||||
}
|
||||
}
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
@ -146,11 +244,11 @@ export default function CommerceManagementPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, search, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage) return;
|
||||
const refreshTimer = window.setTimeout(() => void loadData(), 0);
|
||||
const refreshTimer = window.setTimeout(() => void loadData(), 250);
|
||||
return () => window.clearTimeout(refreshTimer);
|
||||
}, [canManage, loadData]);
|
||||
|
||||
@ -164,6 +262,23 @@ export default function CommerceManagementPage() {
|
||||
);
|
||||
const selectedSale = sales.find((sale) => sale.id === selectedSaleId) ?? null;
|
||||
|
||||
const searchPlaceholders: Partial<Record<Tab, string>> = {
|
||||
products: "Название, артикул или штрихкод",
|
||||
sources: "Название или код источника",
|
||||
bindings: "Товар, внешний ID или SKU",
|
||||
sales: "Номер продажи, товар или SKU",
|
||||
costs: "Товар, канал или внешний ID",
|
||||
stock: "Товар, склад или артикул",
|
||||
};
|
||||
|
||||
function selectTab(nextTab: Tab) {
|
||||
setTab(nextTab);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
setListCount(0);
|
||||
setSelectedSaleId(null);
|
||||
}
|
||||
|
||||
function openCreate(kind: EditorKind) {
|
||||
const now = localDateTime();
|
||||
const defaults: Record<EditorKind, FormState> = {
|
||||
@ -287,27 +402,61 @@ export default function CommerceManagementPage() {
|
||||
|
||||
return (
|
||||
<AppShell desktopWideMode>
|
||||
<main className="mx-auto w-full max-w-[1600px] space-y-5 p-4 sm:p-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Link href="/products" className="mb-2 inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К каталогу</Link>
|
||||
<h1 className="text-2xl font-semibold">Управление товарами и аналитикой</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">Ручное управление справочниками и нормализованными фактами.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadData()} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><RefreshCw className="h-4 w-4" /> Обновить</button>
|
||||
</header>
|
||||
<main className="mx-auto w-full min-w-0 max-w-[1600px] space-y-4 p-4 sm:p-6 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
|
||||
<Link href="/products" className="app-text-muted inline-flex items-center gap-2 text-sm hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К каталогу</Link>
|
||||
|
||||
<section className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold sm:text-2xl">Управление товарами</h1>
|
||||
{tab !== "connections" && !loading ? (
|
||||
<span className="app-counter inline-flex h-6 min-w-6 items-center justify-center px-2 text-[11px] font-bold">
|
||||
{listCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-sm">Каталог, подключения, продажи и складские данные.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tab === "connections" ? setConnectionsRefreshKey((current) => current + 1) : void loadData()}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> Обновить
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav className="mt-4 flex max-w-full gap-1 overflow-x-auto rounded-xl bg-[var(--surface-secondary)] p-1.5" aria-label="Разделы управления товарами">
|
||||
{tabs.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button key={item.id} type="button" onClick={() => selectTab(item.id)} aria-current={tab === item.id ? "page" : undefined} className={`inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition ${tab === item.id ? "app-selected" : "app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"}`}><Icon className="h-4 w-4" /> {item.label}</button>;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{tab !== "connections" ? (
|
||||
<label className="relative mt-4 block min-w-0">
|
||||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={searchPlaceholders[tab] || "Поиск"}
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
aria-label={`Поиск: ${tabs.find((item) => item.id === tab)?.label || "данные"}`}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
|
||||
|
||||
<nav className="app-surface flex gap-1 overflow-x-auto rounded-xl p-1.5">
|
||||
{tabs.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button key={item.id} type="button" onClick={() => setTab(item.id)} className={`inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${tab === item.id ? "app-selected" : "hover:bg-[var(--surface-secondary)]"}`}><Icon className="h-4 w-4" /> {item.label}</button>;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{loading ? <div className="app-surface rounded-xl p-6 text-sm text-[var(--muted-foreground)]">Загрузка…</div> : (
|
||||
<>
|
||||
{tab === "connections" ? <ProductConnectionsContent embedded refreshKey={connectionsRefreshKey} /> : null}
|
||||
{tab === "products" ? <ProductsTable items={products} onCreate={() => openCreate("product")} onEdit={(item) => openEdit("product", item)} onDelete={(id) => void remove("product", id)} /> : null}
|
||||
{tab === "sources" ? <SourcesTable items={sources} onCreate={() => openCreate("source")} onEdit={(item) => openEdit("source", item)} onDelete={(id) => void remove("source", id)} /> : null}
|
||||
{tab === "bindings" ? <BindingsTable items={bindings} productMap={productMap} sourceMap={sourceMap} onCreate={() => openCreate("binding")} onEdit={(item) => openEdit("binding", item)} onDelete={(id) => void remove("binding", id)} /> : null}
|
||||
@ -317,6 +466,19 @@ export default function CommerceManagementPage() {
|
||||
{tab === "stock" ? <StockTable items={stock} productMap={productMap} sourceMap={sourceMap} onCreate={() => openCreate("stock")} onEdit={(item) => openEdit("stock", item)} onDelete={(id) => void remove("stock", id)} /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab !== "connections" && !loading && listCount > 0 ? (
|
||||
<footer className="app-surface flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
|
||||
<p className="app-text-muted text-xs">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, listCount)} из {listCount}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={!hasPreviousPage} onClick={() => setPage((current) => Math.max(1, current - 1))} className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-40" aria-label="Предыдущая страница"><ChevronLeft className="h-4 w-4" /></button>
|
||||
<span className="app-badge inline-flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-xs font-semibold">{page}</span>
|
||||
<button type="button" disabled={!hasNextPage} onClick={() => setPage((current) => current + 1)} className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-40" aria-label="Следующая страница"><ChevronRight className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<Modal isOpen={editor !== null} onClose={() => setEditor(null)} title={editorTitle(editor)} size="lg" footer={<div className="flex justify-end gap-2 pb-4"><button type="button" onClick={() => setEditor(null)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button><button type="submit" form="commerce-editor-form" disabled={saving} className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50">{saving ? "Сохранение…" : "Сохранить"}</button></div>}>
|
||||
@ -407,13 +569,13 @@ function EditorFields({ kind, editorId, form, setForm, products, sources, sales
|
||||
type TableActions<TId extends string | number, TItem> = { onCreate: () => void; onEdit: (item: TItem) => void; onDelete: (id: TId) => void };
|
||||
|
||||
function Section({ title, subtitle, onCreate, children }: { title: string; subtitle: string; onCreate: () => void; children: ReactNode }) {
|
||||
return <section className="app-surface overflow-hidden rounded-xl"><header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-4"><div><h2 className="font-semibold">{title}</h2><p className="mt-1 text-xs text-[var(--muted-foreground)]">{subtitle}</p></div><button type="button" onClick={onCreate} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><Plus className="h-4 w-4" /> Добавить</button></header>{children}</section>;
|
||||
return <section className="app-surface min-w-0 overflow-hidden rounded-2xl"><header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-4"><div className="min-w-0"><h2 className="font-semibold">{title}</h2><p className="app-text-muted mt-1 text-xs">{subtitle}</p></div><button type="button" onClick={onCreate} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><Plus className="h-4 w-4" /> Добавить</button></header>{children}</section>;
|
||||
}
|
||||
|
||||
function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return <div className="flex justify-end gap-1"><button type="button" onClick={onEdit} className="app-icon-button rounded-lg p-2" title="Изменить"><Pencil className="h-4 w-4" /></button><button type="button" onClick={onDelete} className="app-icon-button rounded-lg p-2 text-rose-600" title="Удалить"><Trash2 className="h-4 w-4" /></button></div>; }
|
||||
function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return <div className="flex justify-end gap-1"><button type="button" onClick={onEdit} className="app-icon-button rounded-lg p-2" title="Изменить" aria-label="Изменить запись"><Pencil className="h-4 w-4" /></button><button type="button" onClick={onDelete} className="app-icon-button rounded-lg p-2 text-rose-600" title="Удалить" aria-label="Удалить запись"><Trash2 className="h-4 w-4" /></button></div>; }
|
||||
function EmptyRow({ columns }: { columns: number }) { return <tr><td colSpan={columns} className="p-8 text-center text-sm text-[var(--muted-foreground)]">Записей пока нет.</td></tr>; }
|
||||
|
||||
function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions<string, CommerceProduct>) { return <Section title="Товары" subtitle="Единые внутренние SKU" onCreate={onCreate}><Table headers={["SKU", "Название", "Тип", "Привязки", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.sku}</Td><Td>{item.name}</Td><Td>{item.kind}</Td><Td>{item.bindings.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
|
||||
function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions<string, CommerceProduct>) { return <Section title="Товары" subtitle="Единый каталог и внутренние артикулы" onCreate={onCreate}><Table headers={["SKU", "Название", "Тип", "Привязки", ""]}>{items.length ? items.map((item) => <tr key={item.id} className="transition hover:bg-[var(--surface-secondary)]"><Td><span className="font-medium">{item.sku}</span></Td><Td>{item.name}</Td><Td><span className="app-badge rounded-full px-2 py-0.5 text-[10px]">{productKindLabels[item.kind]}</span></Td><Td>{item.bindings.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
|
||||
function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions<number, SourceSystem>) { return <Section title="Источники" subtitle="Сервисы, API и импортируемые отчёты" onCreate={onCreate}><Table headers={["Код", "Название", "Статус", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.code}</Td><Td>{item.name}</Td><Td>{item.is_active ? "Активен" : "Отключён"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={4} />}</Table></Section>; }
|
||||
function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, ProductBinding>) { return <Section title="Внешние привязки" subtitle="Соответствия товаров и офферов разных систем" onCreate={onCreate}><Table headers={["Товар", "Источник", "Внешний ID", "Внешний SKU", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.sku ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.external_id}</Td><Td>{item.external_sku || "—"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
|
||||
function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map<number, SourceSystem>; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions<string, CommerceSale>) { return <Section title="Продажи и возвраты" subtitle="Выберите продажу, чтобы управлять её позициями" onCreate={onCreate}><Table headers={["Номер", "Источник", "Канал", "Статус", "Дата", "Позиции", ""]}>{items.length ? items.map((item) => <tr key={item.id} className={selectedSaleId === item.id ? "bg-[var(--surface-secondary)]" : ""}><Td><button type="button" onClick={() => onSelect(item.id)} className="font-medium hover:underline">{item.number || item.external_id}</button></Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.channel || "—"}</Td><Td>{item.status || item.event_type}</Td><Td>{displayDate(item.occurred_at)}</Td><Td>{item.lines.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
|
||||
@ -421,8 +583,8 @@ function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale
|
||||
function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceCost>) { return <Section title="Расходы" subtitle="Комиссии, логистика, реклама и прочие затраты" onCreate={onCreate}><Table headers={["Товар", "Источник", "Тип", "Канал", "Сумма", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{costTypeLabels[item.cost_type] ?? item.cost_type}</Td><Td>{item.channel || "—"}</Td><Td>{displayNumber(item.amount)} {item.currency}</Td><Td>{displayDate(item.occurred_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
|
||||
function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceStockSnapshot>) { return <Section title="Снимки остатков" subtitle="Наблюдения по складам и источникам" onCreate={onCreate}><Table headers={["Товар", "Источник", "Склад", "Количество", "Резерв", "Доступно", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.name ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.warehouse_name || item.warehouse_external_id}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.reserved, 4)}</Td><Td>{displayNumber(item.available, 4)}</Td><Td>{displayDate(item.captured_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={8} />}</Table></Section>; }
|
||||
|
||||
function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return <div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="bg-[var(--surface-secondary)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]"><tr>{headers.map((header, index) => <th key={`${header}-${index}`} className="px-4 py-3 font-medium">{header}</th>)}</tr></thead><tbody className="divide-y divide-[var(--border-subtle)]">{children}</tbody></table></div>; }
|
||||
function Td({ children }: { children: ReactNode }) { return <td className="px-4 py-3 align-middle">{children}</td>; }
|
||||
function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return <div className="max-w-full overflow-x-auto overscroll-x-contain"><table className="w-full min-w-[760px] text-left text-sm"><thead className="sticky top-0 z-10 bg-[var(--surface-secondary)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]"><tr>{headers.map((header, index) => <th key={`${header}-${index}`} className="px-4 py-3 font-medium">{header}</th>)}</tr></thead><tbody className="divide-y divide-[var(--border-subtle)]">{children}</tbody></table></div>; }
|
||||
function Td({ children }: { children: ReactNode }) { return <td className="max-w-sm break-words px-4 py-3 align-middle">{children}</td>; }
|
||||
|
||||
function Field({ label, value, onChange, required = false, type = "text", step, placeholder, multiline = false, className = "" }: { label: string; value: string; onChange: (value: string) => void; required?: boolean; type?: string; step?: string; placeholder?: string; multiline?: boolean; className?: string }) { const controlClass = "mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]"; return <label className={className}><span className="text-xs font-medium text-[var(--muted-foreground)]">{label}{required ? " *" : ""}</span>{multiline ? <textarea required={required} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} rows={4} className={controlClass} /> : <input required={required} type={type} step={step} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={controlClass} />}</label>; }
|
||||
function SelectField({ label, value, onChange, options, required = false, emptyLabel = "Выберите" }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }>; required?: boolean; emptyLabel?: string }) { return <label><span className="text-xs font-medium text-[var(--muted-foreground)]">{label}{required ? " *" : ""}</span><select required={required} value={value} onChange={(event) => onChange(event.target.value)} className="mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none"><option value="">{emptyLabel}</option>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>; }
|
||||
|
||||
@ -3,27 +3,56 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { CommerceProduct } from "@/types/commerce";
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ImageIcon,
|
||||
Layers3,
|
||||
Link2,
|
||||
PackageSearch,
|
||||
PlugZap,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const kindLabels: Record<CommerceProduct["kind"], string> = {
|
||||
product: "Товар",
|
||||
service: "Услуга",
|
||||
bundle: "Комплект",
|
||||
};
|
||||
|
||||
const kindFilters: Array<{
|
||||
value: "" | CommerceProduct["kind"];
|
||||
label: string;
|
||||
icon: typeof Boxes;
|
||||
}> = [
|
||||
{ value: "", label: "Все", icon: Boxes },
|
||||
{ value: "product", label: "Товары", icon: PackageSearch },
|
||||
{ value: "service", label: "Услуги", icon: SlidersHorizontal },
|
||||
{ value: "bundle", label: "Комплекты", icon: Layers3 },
|
||||
];
|
||||
|
||||
export default function ProductsPage() {
|
||||
const { user } = useUser();
|
||||
const [products, setProducts] = useState<CommerceProduct[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [kind, setKind] = useState<"" | CommerceProduct["kind"]>("");
|
||||
const [ordering, setOrdering] = useState("name");
|
||||
const [page, setPage] = useState(1);
|
||||
const [count, setCount] = useState(0);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [hasPreviousPage, setHasPreviousPage] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canManage = Boolean(
|
||||
@ -36,16 +65,22 @@ export default function ProductsPage() {
|
||||
),
|
||||
);
|
||||
|
||||
const loadProducts = useCallback(async (query = "") => {
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.getCommerceProducts({
|
||||
search: query || undefined,
|
||||
ordering: "name",
|
||||
search: search.trim() || undefined,
|
||||
kind: kind || undefined,
|
||||
ordering,
|
||||
is_active: true,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
setProducts(response.results);
|
||||
setCount(response.count);
|
||||
setHasNextPage(Boolean(response.next));
|
||||
setHasPreviousPage(Boolean(response.previous));
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
@ -55,93 +90,229 @@ export default function ProductsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [kind, ordering, page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => void loadProducts(search.trim()), 250);
|
||||
const timeout = window.setTimeout(() => void loadProducts(), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [loadProducts, search]);
|
||||
}, [loadProducts]);
|
||||
|
||||
function updateSearch(value: string) {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function updateKind(value: "" | CommerceProduct["kind"]) {
|
||||
setKind(value);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function updateOrdering(value: string) {
|
||||
setOrdering(value);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const pageFrom = count === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
||||
const pageTo = Math.min(page * PAGE_SIZE, count);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="mx-auto w-full max-w-7xl space-y-5 p-4 sm:p-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="app-text-muted text-sm font-semibold uppercase tracking-wide">
|
||||
Товары
|
||||
</h1>
|
||||
<section className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
|
||||
<header className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
|
||||
Товары
|
||||
</p>
|
||||
<span className="app-counter inline-flex h-6 min-w-6 items-center justify-center px-2 text-[11px] font-bold">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/products/connections"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" /> Подключить сервис
|
||||
</Link>
|
||||
<Link
|
||||
href="/products/manage"
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" /> Управление
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/products/manage"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" /> Управление
|
||||
</Link>
|
||||
) : null}
|
||||
</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>
|
||||
<div className="mb-4 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => updateSearch(event.target.value)}
|
||||
placeholder="Поиск по товарам"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
aria-label="Поиск по товарам"
|
||||
/>
|
||||
</label>
|
||||
<label className="relative w-full shrink-0 sm:w-44">
|
||||
<ArrowDownAZ className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<select
|
||||
value={ordering}
|
||||
onChange={(event) => updateOrdering(event.target.value)}
|
||||
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
|
||||
aria-label="Сортировка товаров"
|
||||
>
|
||||
<option value="name">По названию</option>
|
||||
<option value="sku">По артикулу</option>
|
||||
<option value="-updated_at">Недавно обновлённые</option>
|
||||
<option value="-created_at">Недавно добавленные</option>
|
||||
</select>
|
||||
<ChevronDown className="app-text-muted pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2 overflow-x-auto pb-1" aria-label="Тип товара">
|
||||
{kindFilters.map((filter) => {
|
||||
const Icon = filter.icon;
|
||||
const active = filter.value === kind;
|
||||
return (
|
||||
<button
|
||||
key={filter.value || "all"}
|
||||
type="button"
|
||||
onClick={() => updateKind(filter.value)}
|
||||
className={`${active ? "app-selected" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" /> {filter.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div>
|
||||
<div className="app-feedback-danger mb-4 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)]">
|
||||
Товары пока не добавлены или не найдены.
|
||||
<div className="relative min-h-52">
|
||||
{loading && products.length > 0 ? (
|
||||
<div className="absolute inset-x-0 top-0 z-10 h-0.5 overflow-hidden rounded-full bg-[var(--surface-secondary)]">
|
||||
<div className="h-full w-1/2 animate-pulse bg-[var(--accent-primary)]" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && products.length === 0 ? (
|
||||
<div className="space-y-2" aria-label="Загрузка товаров">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="app-surface-muted flex animate-pulse gap-3 rounded-xl p-3">
|
||||
<div className="h-16 w-16 shrink-0 rounded-xl bg-[var(--surface-tertiary)]" />
|
||||
<div className="flex flex-1 flex-col justify-center gap-2">
|
||||
<div className="h-4 w-3/4 rounded bg-[var(--surface-tertiary)]" />
|
||||
<div className="h-3 w-1/2 rounded bg-[var(--surface-tertiary)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loading && products.length === 0 ? (
|
||||
<div className="app-surface-muted flex min-h-52 flex-col items-center justify-center rounded-xl p-8 text-center">
|
||||
<PackageSearch className="app-text-muted h-8 w-8" />
|
||||
<p className="mt-3 text-sm font-medium text-[var(--foreground)]">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 max-w-sm text-xs">
|
||||
Измените поисковый запрос или выберите другой тип товара.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<div className="space-y-2">
|
||||
{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>
|
||||
<ProductListItem key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{count > 0 ? (
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border-subtle)] pt-4">
|
||||
<p className="app-text-muted text-xs">
|
||||
{pageFrom}–{pageTo} из {count}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={!hasPreviousPage || loading}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Назад
|
||||
</button>
|
||||
<span className="app-badge inline-flex min-w-9 items-center justify-center rounded-lg px-2 py-2 text-xs font-semibold">
|
||||
{page}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Далее <ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductListItem({ product }: { product: CommerceProduct }) {
|
||||
const image = product.images[0];
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="group flex min-w-0 items-center gap-3 p-3"
|
||||
>
|
||||
<span className="relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[var(--surface-tertiary)]">
|
||||
{image ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(image.thumbnail_url || image.url)}
|
||||
alt={image.title || product.name}
|
||||
fill
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
className="object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<PackageSearch className="app-text-muted h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-start gap-2">
|
||||
<span className="app-text-wrap min-w-0 flex-1 line-clamp-2 text-sm font-semibold text-[var(--foreground)] transition group-hover:text-[var(--accent-primary-strong)]">
|
||||
{product.name}
|
||||
</span>
|
||||
<span className="app-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium">
|
||||
{kindLabels[product.kind]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-xs">
|
||||
Артикул: {product.sku || "—"}
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Link2 className="h-2.5 w-2.5" /> {product.bindings.length}
|
||||
</span>
|
||||
{product.images.length > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<ImageIcon className="h-2.5 w-2.5" /> {product.images.length}
|
||||
</span>
|
||||
) : null}
|
||||
{product.variants_count > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Layers3 className="h-2.5 w-2.5" /> {product.variants_count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="app-text-muted h-4 w-4 shrink-0 transition group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)]" />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import { usePwa } from "@/contexts/PwaContext";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
import type { GuestVisit, GuestVisitComment } from "@/types/api";
|
||||
|
||||
type AppShellProps = {
|
||||
@ -502,39 +503,85 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isWsConnected, setIsWsConnected] = useState(
|
||||
() => typeof WebSocket !== "undefined" && wsManager.getState() === WebSocket.OPEN,
|
||||
);
|
||||
const pendingRequestInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const lastPendingRequestSyncAtRef = useRef(0);
|
||||
const lastPendingRequestUserIdRef = useRef<number | null>(null);
|
||||
|
||||
const loadPendingRequest = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.getGuestVisits({
|
||||
scope: "mine",
|
||||
status: "needs_info",
|
||||
ordering: "-updated_at",
|
||||
page: 1,
|
||||
limit: 1,
|
||||
}) as PaginatedResponse<GuestVisit> | GuestVisit[];
|
||||
const pendingVisit = toResults(response)[0] || null;
|
||||
setVisit(pendingVisit);
|
||||
if (!pendingVisit) {
|
||||
setComments([]);
|
||||
setAnswer("");
|
||||
return;
|
||||
}
|
||||
const commentResponse = await apiClient.getGuestVisitComments(pendingVisit.id) as GuestVisitComment[];
|
||||
setComments(commentResponse);
|
||||
} catch {
|
||||
setVisit(null);
|
||||
setComments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const loadPendingRequest = useCallback((force = false) => {
|
||||
if (!userId) return Promise.resolve();
|
||||
if (lastPendingRequestUserIdRef.current !== userId) {
|
||||
lastPendingRequestUserIdRef.current = userId;
|
||||
lastPendingRequestSyncAtRef.current = 0;
|
||||
}
|
||||
if (pendingRequestInFlightRef.current) return pendingRequestInFlightRef.current;
|
||||
const now = Date.now();
|
||||
if (!force && now - lastPendingRequestSyncAtRef.current < 5_000) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
lastPendingRequestSyncAtRef.current = now;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.getGuestVisits({
|
||||
scope: "mine",
|
||||
status: "needs_info",
|
||||
ordering: "-updated_at",
|
||||
page: 1,
|
||||
limit: 1,
|
||||
}) as PaginatedResponse<GuestVisit> | GuestVisit[];
|
||||
const pendingVisit = toResults(response)[0] || null;
|
||||
setVisit(pendingVisit);
|
||||
if (!pendingVisit) {
|
||||
setComments([]);
|
||||
setAnswer("");
|
||||
return;
|
||||
}
|
||||
const commentResponse = await apiClient.getGuestVisitComments(pendingVisit.id) as GuestVisitComment[];
|
||||
setComments(commentResponse);
|
||||
} catch {
|
||||
setVisit(null);
|
||||
setComments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
pendingRequestInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
pendingRequestInFlightRef.current = request;
|
||||
return request;
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
void loadPendingRequest();
|
||||
const interval = window.setInterval(() => void loadPendingRequest(), 30000);
|
||||
const initialLoad = window.setTimeout(() => void loadPendingRequest(), 0);
|
||||
|
||||
const unsubscribeMessages = wsManager.subscribe((event) => {
|
||||
if (event.type !== "notification") return;
|
||||
const notification = event.notification as { verb?: string } | undefined;
|
||||
if (notification?.verb?.startsWith("guest_")) {
|
||||
void loadPendingRequest(true);
|
||||
}
|
||||
});
|
||||
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
|
||||
setIsWsConnected(status.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initialLoad);
|
||||
unsubscribeMessages();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
}, [loadPendingRequest, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const interval = window.setInterval(
|
||||
() => void loadPendingRequest(),
|
||||
isWsConnected ? 5 * 60_000 : 30_000,
|
||||
);
|
||||
|
||||
const handleFocus = () => void loadPendingRequest();
|
||||
const handleVisibility = () => {
|
||||
@ -548,7 +595,7 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, [loadPendingRequest, userId]);
|
||||
}, [isWsConnected, loadPendingRequest, userId]);
|
||||
|
||||
const question = useMemo(
|
||||
() => visit ? latestInfoRequestText(visit, comments) : "",
|
||||
|
||||
377
frontend/src/components/commerce/ProductConnectionsContent.tsx
Normal file
377
frontend/src/components/commerce/ProductConnectionsContent.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
mergeConnectionRealtimeUpdate,
|
||||
type IntegrationConnectionRealtimeUpdate,
|
||||
} from "@/lib/commerce-realtime";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
import type {
|
||||
IntegrationConnection,
|
||||
IntegrationConnectionInput,
|
||||
} from "@/types/commerce";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
CloudDownload,
|
||||
Clock3,
|
||||
ImageIcon,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const statusLabels: Record<IntegrationConnection["status"], string> = {
|
||||
disconnected: "Нужна проверка",
|
||||
ready: "Подключено",
|
||||
queued: "В очереди",
|
||||
syncing: "Импорт выполняется",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
function displayDate(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function ProductConnectionsContent({
|
||||
embedded = false,
|
||||
refreshKey = 0,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const { user, loading: userLoading } = useUser();
|
||||
const [connections, setConnections] = useState<IntegrationConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [authType, setAuthType] = useState<"token" | "basic">("token");
|
||||
const [token, setToken] = useState("");
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isWsConnected, setIsWsConnected] = useState(
|
||||
() => typeof WebSocket !== "undefined" && wsManager.getState() === WebSocket.OPEN,
|
||||
);
|
||||
|
||||
const permissions = user?.auth?.permissions_by_app?.commerce ?? [];
|
||||
const canManage = Boolean(
|
||||
user?.auth?.is_staff ||
|
||||
user?.auth?.is_superuser ||
|
||||
permissions.some(
|
||||
(permission) =>
|
||||
permission.startsWith("add_") || permission.startsWith("change_"),
|
||||
),
|
||||
);
|
||||
|
||||
const moysklad = useMemo(
|
||||
() => connections.find((connection) => connection.provider === "moysklad"),
|
||||
[connections],
|
||||
);
|
||||
|
||||
const loadConnections = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.getCommerceConnections();
|
||||
setConnections(response.results);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось загрузить подключения.",
|
||||
);
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage) return;
|
||||
const timer = window.setTimeout(() => void loadConnections(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canManage, loadConnections, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeMessages = wsManager.subscribe((event) => {
|
||||
if (event.type !== "commerce_connection_updated") return;
|
||||
const update = event.connection as
|
||||
| IntegrationConnectionRealtimeUpdate
|
||||
| undefined;
|
||||
if (!update?.id) return;
|
||||
setConnections((current) =>
|
||||
current.map((connection) =>
|
||||
connection.id === update.id
|
||||
? mergeConnectionRealtimeUpdate(connection, update)
|
||||
: connection,
|
||||
),
|
||||
);
|
||||
});
|
||||
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
|
||||
setIsWsConnected(status.isConnected);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeMessages();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Страховочная сверка нужна только при недоступном WebSocket. При живом
|
||||
// соединении статусы queued/syncing приходят событиями без polling.
|
||||
useEffect(() => {
|
||||
const active = connections.some((connection) =>
|
||||
["queued", "syncing"].includes(connection.status),
|
||||
);
|
||||
if (!active || isWsConnected) return;
|
||||
const interval = window.setInterval(
|
||||
() => void loadConnections(true),
|
||||
30_000,
|
||||
);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [connections, isWsConnected, loadConnections]);
|
||||
|
||||
async function connectAndImport(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy("connect");
|
||||
setError(null);
|
||||
const payload: IntegrationConnectionInput = {
|
||||
provider: "moysklad",
|
||||
auth_type: authType,
|
||||
...(authType === "token" ? { token } : { login, password }),
|
||||
};
|
||||
try {
|
||||
const connection = moysklad
|
||||
? await apiClient.updateCommerceConnection(moysklad.id, payload)
|
||||
: await apiClient.createCommerceConnection(payload);
|
||||
const tested = await apiClient.testCommerceConnection(connection.id);
|
||||
const run = await apiClient.syncCommerceConnection(connection.id);
|
||||
const queued = mergeConnectionRealtimeUpdate(tested, {
|
||||
id: tested.id,
|
||||
status: "queued",
|
||||
task_id: run.task_id,
|
||||
recent_run: run,
|
||||
});
|
||||
setConnections((current) =>
|
||||
current.some((item) => item.id === queued.id)
|
||||
? current.map((item) => (item.id === queued.id ? queued : item))
|
||||
: [queued, ...current],
|
||||
);
|
||||
setToken("");
|
||||
setPassword("");
|
||||
setEditing(false);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось подключить МойСклад.",
|
||||
);
|
||||
await loadConnections(true);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function startSync(connection: IntegrationConnection) {
|
||||
setBusy(connection.id);
|
||||
setError(null);
|
||||
try {
|
||||
const run = await apiClient.syncCommerceConnection(connection.id);
|
||||
setConnections((current) =>
|
||||
current.map((item) =>
|
||||
item.id === connection.id
|
||||
? mergeConnectionRealtimeUpdate(item, {
|
||||
id: item.id,
|
||||
status: "queued",
|
||||
task_id: run.task_id,
|
||||
recent_run: run,
|
||||
})
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось запустить импорт.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSchedule(
|
||||
connection: IntegrationConnection,
|
||||
changes: Pick<
|
||||
IntegrationConnectionInput,
|
||||
"auto_sync" | "sync_interval_minutes"
|
||||
>,
|
||||
) {
|
||||
setBusy(connection.id);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await apiClient.updateCommerceConnection(
|
||||
connection.id,
|
||||
changes,
|
||||
);
|
||||
setConnections((current) =>
|
||||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось изменить расписание.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(connection: IntegrationConnection) {
|
||||
if (!window.confirm("Отключить сервис? Уже импортированные товары сохранятся.")) {
|
||||
return;
|
||||
}
|
||||
setBusy(connection.id);
|
||||
try {
|
||||
await apiClient.deleteCommerceConnection(connection.id);
|
||||
setConnections((current) => current.filter((item) => item.id !== connection.id));
|
||||
setEditing(false);
|
||||
} catch (requestError) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: "Не удалось отключить сервис.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (userLoading) {
|
||||
return <div className="p-6 text-sm app-text-muted">Проверка прав…</div>;
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return <div className="app-feedback-danger rounded-xl p-5 text-sm">У вас нет прав на подключение сервисов.</div>;
|
||||
}
|
||||
|
||||
const syncActive = moysklad && ["queued", "syncing"].includes(moysklad.status);
|
||||
const currentRun = moysklad?.recent_runs.find((run) =>
|
||||
["queued", "running"].includes(run.status),
|
||||
);
|
||||
const visibleStats = currentRun || moysklad?.last_stats;
|
||||
|
||||
return (
|
||||
<div className={embedded ? "space-y-5" : "mx-auto w-full max-w-6xl space-y-5 p-4 sm:p-6"}>
|
||||
{!embedded ? <header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/products/manage" className="mb-2 inline-flex items-center gap-2 text-sm app-text-muted hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К управлению</Link>
|
||||
<h1 className="app-text-muted text-sm font-semibold uppercase tracking-wide">Подключения</h1>
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadConnections()} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><RefreshCw className="h-4 w-4" /> Обновить</button>
|
||||
</header> : null}
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
|
||||
|
||||
<section className="app-surface rounded-2xl p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-sky-500"><Store className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="font-semibold">МойСклад</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm app-text-muted">Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.</p>
|
||||
</div>
|
||||
</div>
|
||||
{moysklad ? <span className={`rounded-full px-3 py-1 text-xs font-medium ${moysklad.status === "error" ? "bg-rose-500/10 text-rose-500" : syncActive ? "bg-sky-500/10 text-sky-500" : "bg-emerald-500/10 text-emerald-500"}`}>{syncActive ? <Loader2 className="mr-1 inline h-3.5 w-3.5 animate-spin" /> : null}{statusLabels[moysklad.status]}</span> : <span className="rounded-full bg-[var(--surface-secondary)] px-3 py-1 text-xs font-medium app-text-muted">Не подключено</span>}
|
||||
</div>
|
||||
|
||||
{loading ? <div className="mt-6 flex items-center gap-2 text-sm app-text-muted"><Loader2 className="h-4 w-4 animate-spin" /> Загрузка…</div> : moysklad && !editing ? (
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<Metric icon={PackageCheck} label="Товаров обработано" value={visibleStats?.processed ?? 0} />
|
||||
<Metric icon={CloudDownload} label="Создано товаров" value={visibleStats?.created_products ?? 0} />
|
||||
<Metric icon={CheckCircle2} label="Сопоставлено" value={visibleStats?.matched_products ?? 0} />
|
||||
<Metric icon={ImageIcon} label="Изображений" value={visibleStats?.images ?? 0} />
|
||||
<Metric icon={Store} label="Остатки" value={visibleStats?.stock_snapshots ?? 0} />
|
||||
<Metric icon={CloudDownload} label="Продаж и возвратов" value={visibleStats?.sales ?? 0} />
|
||||
</div>
|
||||
{(visibleStats?.image_errors ?? 0) > 0 ? (
|
||||
<div className="app-feedback-warning flex gap-2 rounded-xl p-4 text-sm">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
Не удалось загрузить изображений: {visibleStats?.image_errors}. Товары и остальные данные продолжили импортироваться.
|
||||
</div>
|
||||
) : null}
|
||||
{moysklad.last_error ? <div className="app-feedback-danger flex gap-2 rounded-xl p-4 text-sm"><TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {moysklad.last_error}</div> : null}
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><dt className="text-xs app-text-muted">Учётная запись</dt><dd className="mt-1">{moysklad.account_name || "—"}</dd></div>
|
||||
<div><dt className="text-xs app-text-muted">Последняя проверка</dt><dd className="mt-1">{displayDate(moysklad.last_tested_at)}</dd></div>
|
||||
<div><dt className="text-xs app-text-muted">Последний импорт</dt><dd className="mt-1">{displayDate(moysklad.last_sync_finished_at)}</dd></div>
|
||||
</dl>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-[var(--surface-secondary)] p-4">
|
||||
<label className="flex items-center gap-3 text-sm"><input type="checkbox" checked={moysklad.auto_sync} disabled={Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: event.target.checked, sync_interval_minutes: moysklad.sync_interval_minutes })} /><span><span className="block font-medium">Постоянная синхронизация</span><span className="block text-xs app-text-muted">Сервер запускает импорт автоматически</span></span></label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm app-text-muted"><Clock3 className="h-4 w-4" /><span>Каждые</span><select value={moysklad.sync_interval_minutes} disabled={!moysklad.auto_sync || Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: true, sync_interval_minutes: Number(event.target.value) })} className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-1.5 text-sm text-[var(--foreground)] disabled:opacity-50"><option value={5}>5 минут</option><option value={15}>15 минут</option><option value={30}>30 минут</option><option value={60}>1 час</option><option value={360}>6 часов</option><option value={1440}>сутки</option></select></label>
|
||||
<span className="text-xs app-text-muted">Следующий запуск: {moysklad.auto_sync ? displayDate(moysklad.next_sync_at) : "отключён"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void startSync(moysklad)} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50">{syncActive ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {syncActive ? "Импорт выполняется" : "Синхронизировать всё"}</button>
|
||||
<button type="button" disabled={Boolean(busy)} onClick={() => { setAuthType(moysklad.auth_type); setEditing(true); }} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><KeyRound className="h-4 w-4" /> Обновить доступ</button>
|
||||
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void disconnect(moysklad)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-rose-500 disabled:opacity-50"><Trash2 className="h-4 w-4" /> Отключить</button>
|
||||
</div>
|
||||
{moysklad.recent_runs.length ? <div><h3 className="mb-2 text-xs font-semibold uppercase tracking-wide app-text-muted">Последние запуски</h3><div className="divide-y divide-[var(--border-subtle)] rounded-xl border border-[var(--border-subtle)]">{moysklad.recent_runs.map((run) => <div key={run.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"><span>{displayDate(run.created_at)} · {run.status === "success" ? "завершено" : run.status === "error" ? "ошибка" : "выполняется"}</span><span className="app-text-muted">{run.processed} позиций</span></div>)}</div></div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={connectAndImport} className="mt-6 space-y-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.65fr)_minmax(360px,1.35fr)] lg:items-end">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium app-text-muted">Способ авторизации</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setAuthType("token")} className={`rounded-lg px-3 py-2 text-sm ${authType === "token" ? "app-selected" : "app-action-secondary"}`}>API-токен</button>
|
||||
<button type="button" onClick={() => setAuthType("basic")} className={`rounded-lg px-3 py-2 text-sm ${authType === "basic" ? "app-selected" : "app-action-secondary"}`}>Логин и пароль</button>
|
||||
</div>
|
||||
</div>
|
||||
{authType === "token" ? <Field label="Access token МойСклад" type="password" value={token} onChange={setToken} placeholder="Токен без слова Bearer" /> : <div className="grid gap-4 sm:grid-cols-2"><Field label="Логин" value={login} onChange={setLogin} /><Field label="Пароль" type="password" value={password} onChange={setPassword} /></div>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 border-t border-[var(--border-subtle)] pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex max-w-xl items-start gap-2 text-sm app-text-muted"><ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" /><span>Реквизиты шифруются на сервере и никогда не возвращаются в браузер.</span></div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{moysklad ? <button type="button" onClick={() => setEditing(false)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button> : null}
|
||||
<button type="submit" disabled={busy === "connect"} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50">{busy === "connect" ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} Проверить и импортировать всё</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2">
|
||||
<PlannedService name="InSales" description="Каталог, варианты и заказы интернет-магазина" />
|
||||
<PlannedService name="RetailCRM" description="Заказы, каналы, статусы и расходы" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, type = "text", placeholder }: { label: string; value: string; onChange: (value: string) => void; type?: string; placeholder?: string }) {
|
||||
return <label className="block"><span className="text-xs font-medium app-text-muted">{label}</span><input required type={type} autoComplete="off" value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className="mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]" /></label>;
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value }: { icon: typeof Store; label: string; value: number }) {
|
||||
return <div className="rounded-xl bg-[var(--surface-secondary)] p-3"><Icon className="mb-2 h-4 w-4 text-sky-500" /><p className="text-xl font-semibold">{new Intl.NumberFormat("ru-RU").format(value)}</p><p className="mt-0.5 text-xs app-text-muted">{label}</p></div>;
|
||||
}
|
||||
|
||||
function PlannedService({ name, description }: { name: string; description: string }) {
|
||||
return <div className="app-surface rounded-xl p-4 opacity-70"><div className="flex items-center justify-between gap-3"><div><h2 className="font-medium">{name}</h2><p className="mt-1 text-sm app-text-muted">{description}</p></div><span className="shrink-0 rounded-full bg-[var(--surface-secondary)] px-2.5 py-1 text-xs app-text-muted">Следующий этап</span></div></div>;
|
||||
}
|
||||
@ -6,7 +6,9 @@ import wsManager from '@/lib/websocketManager';
|
||||
import { getVerbCategory } from '@/lib/verbTranslations';
|
||||
import { useUser } from '@/contexts/UserContext';
|
||||
|
||||
const NOTIFICATIONS_SYNC_INTERVAL_MS = 30000;
|
||||
const NOTIFICATIONS_FALLBACK_INTERVAL_MS = 30_000;
|
||||
const NOTIFICATIONS_RECONCILE_INTERVAL_MS = 5 * 60_000;
|
||||
const NOTIFICATIONS_RECONCILE_COOLDOWN_MS = 5_000;
|
||||
const UNREAD_NOTIFICATIONS_PAGE_SIZE = 50;
|
||||
|
||||
export type NotificationItem = {
|
||||
@ -79,6 +81,8 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
);
|
||||
const fetchInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const summaryInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const lastReconcileAtRef = useRef(0);
|
||||
const lastReconcileUserIdRef = useRef<number | null>(null);
|
||||
const knownNotificationIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const applyUnreadSummary = useCallback((summary: {
|
||||
@ -245,28 +249,50 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
return request;
|
||||
}, [applyUnreadSummary]);
|
||||
|
||||
const reconcileUnreadState = useCallback(
|
||||
(force = false, showLoader = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastReconcileAtRef.current < NOTIFICATIONS_RECONCILE_COOLDOWN_MS) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
lastReconcileAtRef.current = now;
|
||||
return Promise.all([
|
||||
syncUnreadNotifications(showLoader),
|
||||
syncUnreadSummary(),
|
||||
]).then(() => undefined);
|
||||
},
|
||||
[syncUnreadNotifications, syncUnreadSummary],
|
||||
);
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
if (!authenticatedUserId) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
setUnreadPersonalRegulationCount(0);
|
||||
knownNotificationIdsRef.current.clear();
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
const resetTimer = window.setTimeout(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
setUnreadPersonalRegulationCount(0);
|
||||
knownNotificationIdsRef.current.clear();
|
||||
lastReconcileAtRef.current = 0;
|
||||
lastReconcileUserIdRef.current = null;
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(resetTimer);
|
||||
}
|
||||
|
||||
void syncUnreadNotifications(true);
|
||||
void syncUnreadSummary();
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
if (lastReconcileUserIdRef.current !== authenticatedUserId) {
|
||||
lastReconcileUserIdRef.current = authenticatedUserId;
|
||||
lastReconcileAtRef.current = 0;
|
||||
}
|
||||
void reconcileUnreadState(false, true);
|
||||
}, [authenticatedUserId, reconcileUnreadState]);
|
||||
|
||||
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
|
||||
useEffect(() => {
|
||||
@ -389,20 +415,23 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
};
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
|
||||
// Регулярная сверка с API нужна даже при живом WebSocket: часть событий может не дойти по сокету.
|
||||
// При живом WebSocket API используется только для редкой reconciliation.
|
||||
// Частая страховочная сверка нужна исключительно во время разрыва сокета.
|
||||
useEffect(() => {
|
||||
if (!authenticatedUserId || typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
void syncUnreadSummary();
|
||||
void syncUnreadNotifications(false);
|
||||
void reconcileUnreadState();
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
const intervalId = window.setInterval(sync, NOTIFICATIONS_SYNC_INTERVAL_MS);
|
||||
const intervalId = window.setInterval(
|
||||
sync,
|
||||
isWsConnected
|
||||
? NOTIFICATIONS_RECONCILE_INTERVAL_MS
|
||||
: NOTIFICATIONS_FALLBACK_INTERVAL_MS,
|
||||
);
|
||||
const handleFocus = () => sync();
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
@ -418,16 +447,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
|
||||
}, [authenticatedUserId, isWsConnected, reconcileUnreadState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticatedUserId || !isWsConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
void syncUnreadSummary();
|
||||
void syncUnreadNotifications(false);
|
||||
}, [authenticatedUserId, isWsConnected, syncUnreadNotifications, syncUnreadSummary]);
|
||||
void reconcileUnreadState();
|
||||
}, [authenticatedUserId, isWsConnected, reconcileUnreadState]);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
await apiClient.markNotificationAsRead(id);
|
||||
|
||||
@ -95,8 +95,16 @@ export function createCommerceApi(request: RequestFn) {
|
||||
): Promise<ProductMetrics> =>
|
||||
request(`/api/v1/commerce/products/${id}/analytics/${buildQuery(params)}`),
|
||||
|
||||
getCommerceSources: (): Promise<PaginatedCommerceResponse<SourceSystem>> =>
|
||||
request("/api/v1/commerce/sources/?ordering=name&page_size=200"),
|
||||
getCommerceSources: (
|
||||
params?: Record<string, string | number | boolean>,
|
||||
): Promise<PaginatedCommerceResponse<SourceSystem>> =>
|
||||
request(
|
||||
`/api/v1/commerce/sources/${buildQuery({
|
||||
ordering: "name",
|
||||
page_size: 200,
|
||||
...params,
|
||||
})}`,
|
||||
),
|
||||
createCommerceSource: (data: SourceSystemInput): Promise<SourceSystem> =>
|
||||
request("/api/v1/commerce/sources/", json("POST", data)),
|
||||
updateCommerceSource: (
|
||||
|
||||
42
frontend/src/lib/commerce-realtime.test.js
Normal file
42
frontend/src/lib/commerce-realtime.test.js
Normal file
@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mergeConnectionRealtimeUpdate } from "./commerce-realtime.ts";
|
||||
|
||||
const connection = {
|
||||
id: "connection-1",
|
||||
status: "queued",
|
||||
last_error: "",
|
||||
recent_runs: [
|
||||
{ id: "run-old", connection: "connection-1", status: "success" },
|
||||
],
|
||||
};
|
||||
|
||||
test("commerce realtime update replaces status without polling", () => {
|
||||
const updated = mergeConnectionRealtimeUpdate(connection, {
|
||||
id: "connection-1",
|
||||
status: "ready",
|
||||
last_stats: { processed: 42 },
|
||||
recent_run: {
|
||||
id: "run-new",
|
||||
connection: "connection-1",
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updated.status, "ready");
|
||||
assert.deepEqual(updated.last_stats, { processed: 42 });
|
||||
assert.deepEqual(
|
||||
updated.recent_runs.map((run) => run.id),
|
||||
["run-new", "run-old"],
|
||||
);
|
||||
});
|
||||
|
||||
test("repeated commerce realtime update does not duplicate a run", () => {
|
||||
const updated = mergeConnectionRealtimeUpdate(connection, {
|
||||
id: "connection-1",
|
||||
recent_run: connection.recent_runs[0],
|
||||
});
|
||||
|
||||
assert.deepEqual(updated.recent_runs.map((run) => run.id), ["run-old"]);
|
||||
});
|
||||
28
frontend/src/lib/commerce-realtime.ts
Normal file
28
frontend/src/lib/commerce-realtime.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type {
|
||||
IntegrationConnection,
|
||||
IntegrationSyncRun,
|
||||
} from "@/types/commerce";
|
||||
|
||||
export type IntegrationConnectionRealtimeUpdate =
|
||||
Partial<IntegrationConnection> & {
|
||||
id: string;
|
||||
recent_run?: IntegrationSyncRun | null;
|
||||
};
|
||||
|
||||
export function mergeConnectionRealtimeUpdate(
|
||||
connection: IntegrationConnection,
|
||||
update: IntegrationConnectionRealtimeUpdate,
|
||||
): IntegrationConnection {
|
||||
const { recent_run: recentRun, ...fields } = update;
|
||||
const recentRuns = recentRun
|
||||
? [
|
||||
recentRun,
|
||||
...connection.recent_runs.filter((run) => run.id !== recentRun.id),
|
||||
].slice(0, 5)
|
||||
: connection.recent_runs;
|
||||
return {
|
||||
...connection,
|
||||
...fields,
|
||||
recent_runs: recentRuns,
|
||||
};
|
||||
}
|
||||
@ -12,6 +12,7 @@ export type ProductBinding = {
|
||||
source_code: string;
|
||||
external_id: string;
|
||||
external_sku: string;
|
||||
metadata: Record<string, unknown>;
|
||||
is_primary: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -36,6 +37,9 @@ export type IntegrationSyncRun = {
|
||||
created_products: number;
|
||||
matched_products: number;
|
||||
stock_snapshots: number;
|
||||
images: number;
|
||||
image_errors: number;
|
||||
sales: number;
|
||||
error: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
@ -60,6 +64,8 @@ export type IntegrationConnection = {
|
||||
created_products: number;
|
||||
matched_products: number;
|
||||
stock_snapshots: number;
|
||||
images: number;
|
||||
image_errors: number;
|
||||
sales: number;
|
||||
sale_lines: number;
|
||||
}>;
|
||||
@ -82,6 +88,18 @@ export type IntegrationConnectionInput = {
|
||||
sync_interval_minutes?: number;
|
||||
};
|
||||
|
||||
export type CommerceProductImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail_url: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
source_size: number;
|
||||
position: number;
|
||||
is_primary: boolean;
|
||||
source_updated_at: string | null;
|
||||
};
|
||||
|
||||
export type CommerceProduct = {
|
||||
id: string;
|
||||
parent: string | null;
|
||||
@ -93,6 +111,7 @@ export type CommerceProduct = {
|
||||
is_active: boolean;
|
||||
variants_count: number;
|
||||
bindings: ProductBinding[];
|
||||
images: CommerceProductImage[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -186,6 +205,7 @@ export type ProductBindingInput = {
|
||||
source: number;
|
||||
external_id: string;
|
||||
external_sku?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
is_primary?: boolean;
|
||||
};
|
||||
|
||||
@ -251,6 +271,8 @@ export type ProductMonetaryMetrics = {
|
||||
additional_costs: string;
|
||||
contribution_margin: string;
|
||||
cost_breakdown: Record<string, string>;
|
||||
missing_cost_lines: number;
|
||||
cost_complete: boolean;
|
||||
};
|
||||
|
||||
export type ProductStockMetric = {
|
||||
@ -273,6 +295,8 @@ export type ProductChannelMetrics = {
|
||||
additional_costs: string;
|
||||
contribution_margin: string;
|
||||
cost_breakdown: Record<string, string>;
|
||||
missing_cost_lines: number;
|
||||
cost_complete: boolean;
|
||||
};
|
||||
|
||||
export type ProductMetrics = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user