417 lines
14 KiB
Python
417 lines
14 KiB
Python
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 ""),
|
||
}
|