Corp/backend/commerce/services/wildberries_sync.py
2026-08-18 18:48:00 +03:00

417 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from dataclasses import asdict, dataclass
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any, Callable
from dateutil import parser as date_parser
from django.db import transaction
from django.utils import timezone
from commerce.integrations.wildberries import WildberriesClient, WildberriesError
from commerce.models import (
Cost,
CostType,
IntegrationConnection,
IntegrationSyncStage,
ProductBinding,
Sale,
SaleEventType,
SaleLine,
)
from commerce.services.credentials import decrypt_credentials
from commerce.services.ingestion import ingest_source_record
from commerce.services.matching import match_product
logger = logging.getLogger(__name__)
@dataclass
class WildberriesSyncStats:
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
costs: int = 0
unmatched_products: int = 0
report_complete: bool = True
def to_dict(self) -> dict[str, int | bool]:
return asdict(self)
def _value(row: dict[str, Any], *keys: str, default: Any = "") -> Any:
for key in keys:
value = row.get(key)
if value is not None and value != "":
return value
return default
def _value_with_key(
row: dict[str, Any], *keys: str, default: Any = ""
) -> tuple[str, Any]:
for key in keys:
value = row.get(key)
if value is not None and value != "":
return key, value
return keys[0], default
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or 0).replace(" ", "").replace(",", "."))
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
def _money(value: Any) -> Decimal:
return abs(_decimal(value)).quantize(Decimal("0.01"))
def _signed_money(value: Any) -> Decimal:
return _decimal(value).quantize(Decimal("0.01"))
def _parse_datetime(row: dict[str, Any], *keys: str):
raw = _value(row, *keys)
if not raw:
return timezone.now()
try:
parsed = date_parser.parse(str(raw))
except (TypeError, ValueError, OverflowError) as exc:
raise WildberriesError(
f"Wildberries вернул некорректную дату операции: {raw}."
) from exc
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
def _occurred_at(row: dict[str, Any]):
return _parse_datetime(
row,
"saleDt",
"saleDate",
"sale_dt",
"rrDate",
"rr_dt",
"createDate",
"create_dt",
)
def _source_updated_at(row: dict[str, Any]):
return _parse_datetime(
row,
"createDate",
"create_dt",
"rrDate",
"rr_dt",
"saleDt",
"saleDate",
"sale_dt",
)
def _event(row: dict[str, Any]) -> tuple[str, bool]:
document_type = str(
_value(
row, "docTypeName", "doc_type_name", "sellerOperName", "supplier_oper_name"
)
).casefold()
if "возврат" in document_type or "return" in document_type:
return SaleEventType.RETURN, True
if "продаж" in document_type or "sale" in document_type:
return SaleEventType.SALE, True
# Weekly reports also contain penalties, storage and other service rows.
# They retain a SaleLine as a cost target but must not inflate sales or units.
return SaleEventType.SALE, False
def _bind_product(connection: IntegrationConnection, row: dict[str, Any]):
external_id = str(_value(row, "nmId", "nm_id")).strip()
external_sku = str(
_value(row, "vendorCode", "saName", "sa_name", "supplierArticle")
).strip()
barcode = str(_value(row, "sku", "barcode")).strip()
match = match_product(
source=connection.source,
external_id=external_id,
external_sku=external_sku,
barcode=barcode,
)
identifiable = bool(external_id or external_sku or barcode)
if not match.product:
return None, False, identifiable
if external_id:
ProductBinding.objects.update_or_create(
source=connection.source,
external_id=external_id,
defaults={
"product": match.product,
"external_sku": external_sku,
"metadata": {
"provider": "wildberries",
"nm_id": external_id,
"barcode": barcode,
"brand": str(_value(row, "brandName", "brand_name")),
"subject": str(_value(row, "subjectName", "subject_name")),
},
},
)
return match.product, True, identifiable
_COST_FIELDS = (
(
CostType.COMMISSION,
"commission",
("ppvzSalesCommission", "ppvz_sales_commission", "salesCommission"),
Decimal("1"),
),
(CostType.ACQUIRING, "acquiring", ("acquiringFee", "acquiring_fee"), Decimal("1")),
(
CostType.DELIVERY,
"delivery",
("deliveryService", "delivery_rub", "deliveryRub"),
Decimal("1"),
),
(
CostType.DELIVERY,
"rebill_logistics",
("rebillLogisticCost", "rebill_logistic_cost"),
Decimal("1"),
),
(
CostType.STORAGE,
"storage",
("paidStorage", "storage_fee", "storageFee"),
Decimal("1"),
),
(
CostType.OTHER,
"acceptance",
("paidAcceptance", "acceptance", "acceptance_fee", "acceptanceFee"),
Decimal("1"),
),
(CostType.OTHER, "deduction", ("deduction",), Decimal("1")),
(CostType.OTHER, "penalty", ("penalty",), Decimal("1")),
(
CostType.OTHER,
"cashback",
("cashbackCommissionChange", "cashback_commission_change"),
Decimal("1"),
),
# WB lists this as a payment to the seller. A negative expense preserves
# that direction in contribution-margin calculations.
(
CostType.OTHER,
"additional_payment",
("additionalPayment", "additional_payment"),
Decimal("-1"),
),
)
@transaction.atomic
def normalize_wildberries_finance_row(
*, connection: IntegrationConnection, row: dict[str, Any]
) -> tuple[bool, int, bool, bool]:
rrd_id = str(_value(row, "rrdId", "rrd_id")).strip()
if not rrd_id:
raise WildberriesError("В строке финансового отчёта отсутствует rrdId.")
occurred_at = _occurred_at(row)
source_updated_at = _source_updated_at(row)
record = ingest_source_record(
source=connection.source,
entity_type="financial_report_line",
external_id=rrd_id,
payload=row,
source_updated_at=source_updated_at,
).record
product, matched, identifiable = _bind_product(connection, row)
event_type, counts_as_sale = _event(row)
currency = str(_value(row, "currency", default="RUB"))[:3].upper() or "RUB"
sale, _ = Sale.objects.update_or_create(
source=connection.source,
external_id=f"financial:{rrd_id}",
defaults={
"source_record": record,
"event_type": event_type,
"number": str(_value(row, "srid", "orderUid", "order_uid"))[:128],
"channel": "wildberries",
"status": str(_value(row, "docTypeName", "doc_type_name"))[:128],
"counts_as_sale": counts_as_sale,
"currency": currency,
"occurred_at": occurred_at,
"source_updated_at": source_updated_at,
},
)
quantity = abs(_decimal(_value(row, "quantity", default=0)))
external_product_id = str(_value(row, "nmId", "nm_id"))[:255]
external_sku = str(
_value(row, "vendorCode", "saName", "sa_name", "supplierArticle")
)[:128]
name = str(_value(row, "title", "subjectName", "subject_name"))[:255]
line, _ = SaleLine.objects.update_or_create(
sale=sale,
external_id=rrd_id,
defaults={
"product": product,
"external_product_id": external_product_id,
"external_sku": external_sku,
"name": name,
"quantity": quantity,
"gross_amount": _money(_value(row, "retailAmount", "retail_amount")),
"discount_amount": Decimal("0"),
"cost_amount": Decimal("0"),
"metadata": {
"provider": "wildberries",
"rrd_id": rrd_id,
"report_id": str(_value(row, "reportId", "realizationreport_id")),
"barcode": str(_value(row, "sku", "barcode")),
"size": str(_value(row, "techSize", "size", "tsName", "ts_name")),
"seller_operation": str(
_value(row, "sellerOperName", "supplier_oper_name")
),
"delivery_method": str(
_value(row, "deliveryMethod", "delivery_method")
),
"for_pay": str(_value(row, "forPay", "ppvz_for_pay")),
"cost_source": "unavailable",
},
},
)
costs = 0
Cost.objects.filter(
source=connection.source,
external_id__startswith=f"{rrd_id}:",
).delete()
for cost_type, suffix, keys, multiplier in _COST_FIELDS:
field, raw_amount = _value_with_key(row, *keys)
amount = _signed_money(raw_amount) * multiplier
if not amount:
continue
Cost.objects.update_or_create(
source=connection.source,
external_id=f"{rrd_id}:{suffix}",
cost_type=cost_type,
defaults={
"product": product,
"sale_line": line,
"channel": "wildberries",
"amount": amount,
"currency": currency,
"occurred_at": occurred_at,
"metadata": {"provider": "wildberries", "field": field},
},
)
costs += 1
return matched, costs, identifiable and product is None, counts_as_sale
def sync_wildberries_connection(
connection: IntegrationConnection,
*,
progress_callback: Callable[..., None] | None = None,
) -> WildberriesSyncStats:
credentials = decrypt_credentials(connection)
metadata = connection.source.metadata
try:
cursor = max(int(metadata.get("finance_rrd_id") or 0), 0)
except (TypeError, ValueError):
cursor = 0
if cursor:
date_from = metadata.get("finance_cursor_date_from", "2024-01-29")
date_to = metadata.get("finance_cursor_date_to", date.today().isoformat())
elif metadata.get("finance_initial_complete"):
date_from = (date.today() - timedelta(days=30)).isoformat()
date_to = date.today().isoformat()
else:
date_from = metadata.get("finance_date_from", "2024-01-29")
date_to = date.today().isoformat()
stats = WildberriesSyncStats()
def report(stage, processed, total=0):
if progress_callback:
progress_callback(
stats,
stage=stage,
stage_processed=processed,
stage_total=total,
)
with WildberriesClient(credentials=credentials) as client:
report(IntegrationSyncStage.SALES, 0)
rows = client.financial_report(
date_from=date_from,
date_to=date_to,
rrd_id=cursor,
)
for row in rows:
matched, costs, unmatched, counts_as_sale = (
normalize_wildberries_finance_row(
connection=connection,
row=row,
)
)
stats.processed += 1
stats.sales += int(counts_as_sale)
stats.sale_lines += 1
stats.costs += costs
stats.matched_products += int(matched)
stats.unmatched_products += int(unmatched)
if stats.processed % 250 == 0:
report(IntegrationSyncStage.SALES, stats.processed)
# WB explicitly requires following rrdId pages until the endpoint returns
# 204. A short 200 page is not documented as an end-of-report signal.
stats.report_complete = not rows
updated_metadata = dict(connection.source.metadata)
if rows:
next_cursor = int(_value(rows[-1], "rrdId", "rrd_id"))
if next_cursor <= cursor:
raise WildberriesError(
"Wildberries вернул непоследовательный rrdId финансового отчёта."
)
updated_metadata.update(
{
"finance_rrd_id": next_cursor,
"finance_cursor_date_from": date_from,
"finance_cursor_date_to": date_to,
}
)
else:
updated_metadata.pop("finance_rrd_id", None)
updated_metadata.pop("finance_cursor_date_from", None)
updated_metadata.pop("finance_cursor_date_to", None)
updated_metadata["finance_initial_complete"] = True
report(IntegrationSyncStage.SALES, stats.processed, stats.processed)
connection.source.metadata = updated_metadata
connection.source.save(update_fields=["metadata", "updated_at"])
report(IntegrationSyncStage.FINALIZING, 1, 1)
return stats
def test_wildberries_connection(connection: IntegrationConnection) -> dict[str, str]:
credentials = decrypt_credentials(connection)
with WildberriesClient(credentials=credentials) as client:
context = client.seller_info()
client.finance_ping()
return {
"account_id": str(context.get("sid") or ""),
"account_name": str(
context.get("tradeMark") or context.get("name") or "Wildberries"
),
"tin": str(context.get("tin") or ""),
}