Complete commerce synchronization and realtime updates

This commit is contained in:
lizerk 2026-08-18 09:10:40 +03:00
parent b622370067
commit 156b649b04
45 changed files with 4003 additions and 592 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

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

View File

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

View File

@ -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="Миниатюра",
),
),
]

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
"""Тесты управления активным чатом."""

View File

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

View File

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

View File

@ -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-командами.

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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