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