diff --git a/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py b/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py new file mode 100644 index 00000000..4329486c --- /dev/null +++ b/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py @@ -0,0 +1,133 @@ +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from urllib.parse import urlparse + +from django.db import migrations + + +CENT = Decimal("0.01") +HUNDRED = Decimal("100") + + +def _decimal(value): + try: + return Decimal(str(value or 0)) + except (InvalidOperation, TypeError, ValueError): + return Decimal("0") + + +def _money(value): + return (_decimal(value) / HUNDRED).quantize(CENT, rounding=ROUND_HALF_UP) + + +def _external_id(row, fallback): + value = str(row.get("id") or "").strip() + if value: + return value + meta = row.get("meta") if isinstance(row.get("meta"), dict) else {} + href = str(meta.get("href") or "").rstrip("/") + if href: + return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1] + return str(fallback) + + +def _position_amounts(document, position): + quantity = _decimal(position.get("quantity")) + base_gross = quantity * _decimal(position.get("price")) / HUNDRED + base_net = ( + base_gross + * (HUNDRED - _decimal(position.get("discount"))) + / HUNDRED + ) + multiplier = Decimal("1") + if document.get("vatIncluded") is False and bool(position.get("vatEnabled")): + multiplier += _decimal(position.get("vat")) / HUNDRED + gross = (base_gross * multiplier).quantize(CENT, rounding=ROUND_HALF_UP) + net = (base_net * multiplier).quantize(CENT, rounding=ROUND_HALF_UP) + base_net = base_net.quantize(CENT, rounding=ROUND_HALF_UP) + + raw_cost = _decimal(position.get("cost")) / HUNDRED + cost_source = "position.cost" + if raw_cost <= 0: + assortment = ( + position.get("assortment") + if isinstance(position.get("assortment"), dict) + else {} + ) + buy_price = ( + assortment.get("buyPrice") + if isinstance(assortment.get("buyPrice"), dict) + else {} + ) + raw_cost = _decimal(buy_price.get("value")) / HUNDRED + cost_source = "assortment.buyPrice" if raw_cost > 0 else "unavailable" + cost = (quantity * raw_cost).quantize(CENT, rounding=ROUND_HALF_UP) + return gross, max(Decimal("0"), gross - net), cost, cost_source, net - base_net + + +def reconcile_moysklad_sale_amounts(apps, schema_editor): + Sale = apps.get_model("commerce", "Sale") + SaleLine = apps.get_model("commerce", "SaleLine") + changed = [] + sales = Sale.objects.filter(source__code="moysklad").select_related( + "source_record" + ) + for sale in sales.iterator(chunk_size=200): + if sale.source_record is None: + continue + document = sale.source_record.payload + if not isinstance(document, dict): + continue + positions_value = document.get("positions") + positions = ( + positions_value.get("rows") or [] + if isinstance(positions_value, dict) + else positions_value or [] + ) + lines = { + line.external_id: line + for line in SaleLine.objects.filter(sale_id=sale.pk) + } + for index, position in enumerate(positions, start=1): + if not isinstance(position, dict): + continue + line = lines.get(_external_id(position, index)) + if line is None: + continue + gross, discount, cost, cost_source, vat_amount = _position_amounts( + document, position + ) + metadata = dict(line.metadata or {}) + metadata.update( + { + "provider": "moysklad", + "cost_source": cost_source, + "vat": position.get("vat"), + "vat_enabled": bool(position.get("vatEnabled")), + "vat_amount": str(vat_amount), + "overhead": str(_money(position.get("overhead"))), + } + ) + line.gross_amount = gross + line.discount_amount = discount + line.cost_amount = cost + line.metadata = metadata + changed.append(line) + SaleLine.objects.bulk_update( + changed, + ["gross_amount", "discount_amount", "cost_amount", "metadata"], + batch_size=500, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("commerce", "0008_create_historical_products"), + ] + + operations = [ + migrations.RunPython( + reconcile_moysklad_sale_amounts, + migrations.RunPython.noop, + ), + ] diff --git a/backend/commerce/services/moysklad_sync.py b/backend/commerce/services/moysklad_sync.py index 8e893b9b..b25e526a 100644 --- a/backend/commerce/services/moysklad_sync.py +++ b/backend/commerce/services/moysklad_sync.py @@ -2,7 +2,7 @@ import re import logging from dataclasses import asdict, dataclass from datetime import datetime -from decimal import Decimal, InvalidOperation +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from typing import Any, Callable from urllib.parse import urlparse @@ -72,7 +72,39 @@ def _decimal(value: Any) -> Decimal: def _money(value: Any) -> Decimal: - return (_decimal(value) / Decimal("100")).quantize(Decimal("0.01")) + return (_decimal(value) / Decimal("100")).quantize( + Decimal("0.01"), + rounding=ROUND_HALF_UP, + ) + + +def _position_revenue( + document: dict[str, Any], position: dict[str, Any] +) -> tuple[Decimal, Decimal, Decimal]: + """Return source-compatible gross, discount and added VAT amounts. + + MoySklad may return a fractional price in kopecks. Rounding that unit price + before multiplying by quantity changes the document total. It may also keep + VAT outside the position price while including it in the document ``sum``. + All arithmetic therefore stays at source precision until the line total. + """ + + quantity = _decimal(position.get("quantity")) + base_gross = quantity * _decimal(position.get("price")) / Decimal("100") + discount_percent = _decimal(position.get("discount")) + base_net = base_gross * (Decimal("100") - discount_percent) / Decimal("100") + multiplier = Decimal("1") + if document.get("vatIncluded") is False and bool(position.get("vatEnabled")): + multiplier += _decimal(position.get("vat")) / Decimal("100") + + gross = (base_gross * multiplier).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + net = (base_net * multiplier).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + base_net_rounded = base_net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return gross, max(Decimal("0"), gross - net), net - base_net_rounded def _source_updated_at(row: dict[str, Any]) -> datetime | None: @@ -382,9 +414,14 @@ def _document_channel( def _position_cost(position: dict[str, Any], quantity: Decimal) -> tuple[Decimal, str]: - position_cost = _money(position.get("cost")) + position_cost = _decimal(position.get("cost")) / Decimal("100") if position_cost > 0: - return (quantity * position_cost).quantize(Decimal("0.01")), "position.cost" + return ( + (quantity * position_cost).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ), + "position.cost", + ) assortment = ( position.get("assortment") if isinstance(position.get("assortment"), dict) @@ -392,11 +429,13 @@ def _position_cost(position: dict[str, Any], quantity: Decimal) -> tuple[Decimal ) buy_price = assortment.get("buyPrice") unit_cost = ( - _money(buy_price.get("value")) + _decimal(buy_price.get("value")) / Decimal("100") if isinstance(buy_price, dict) else Decimal("0") ) - return (quantity * unit_cost).quantize(Decimal("0.01")), ( + return (quantity * unit_cost).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ), ( "assortment.buyPrice" if unit_cost > 0 else "unavailable" ) @@ -499,9 +538,7 @@ def normalize_moysklad_document( 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")) + gross, discount, vat_amount = _position_revenue(row, position) cost_amount, cost_source = _position_cost(position, quantity) line, _ = SaleLine.objects.update_or_create( sale=sale, @@ -520,6 +557,7 @@ def normalize_moysklad_document( "cost_source": cost_source, "vat": position.get("vat"), "vat_enabled": bool(position.get("vatEnabled")), + "vat_amount": str(vat_amount), "overhead": str(_money(position.get("overhead"))), }, }, diff --git a/backend/tests/commerce/test_connections.py b/backend/tests/commerce/test_connections.py index 2904e297..8094e82d 100644 --- a/backend/tests/commerce/test_connections.py +++ b/backend/tests/commerce/test_connections.py @@ -1,4 +1,5 @@ from datetime import timedelta +from decimal import Decimal import io from unittest.mock import Mock, patch @@ -32,6 +33,8 @@ from commerce.media import ( from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update from commerce.services.moysklad_sync import ( MoySkladSyncStats, + _position_cost, + _position_revenue, normalize_moysklad_assortment_row, normalize_moysklad_document, normalize_moysklad_stock_row, @@ -722,6 +725,39 @@ def test_moysklad_demand_creates_sale_and_matches_lines(): assert line.metadata["cost_source"] == "assortment.buyPrice" +def test_moysklad_position_amounts_keep_source_precision_and_external_vat(): + precise_position = { + "quantity": 2007, + "price": 1400.0996512207275, + "discount": 0, + "cost": 700.0498256103638, + } + + gross, discount, vat_amount = _position_revenue({}, precise_position) + cost, cost_source = _position_cost(precise_position, Decimal("2007")) + + assert gross == Decimal("28100.00") + assert discount == Decimal("0.00") + assert vat_amount == Decimal("0.00") + assert cost == Decimal("14050.00") + assert cost_source == "position.cost" + + gross, discount, vat_amount = _position_revenue( + {"vatIncluded": False}, + { + "quantity": 1, + "price": 19500000, + "discount": 0, + "vat": 5, + "vatEnabled": True, + }, + ) + + assert gross == Decimal("204750.00") + assert discount == Decimal("0.00") + assert vat_amount == Decimal("9750.00") + + def test_moysklad_document_retains_product_missing_from_current_assortment(): source = SourceSystem.objects.create(code="moysklad", name="МойСклад") connection = IntegrationConnection.objects.create(