Reconcile MoySklad sale totals

This commit is contained in:
lizerk 2026-08-18 09:19:13 +03:00
parent 156b649b04
commit ea0a10f7c4
3 changed files with 216 additions and 9 deletions

View File

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

View File

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

View File

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