Reconcile MoySklad sale totals
This commit is contained in:
parent
156b649b04
commit
ea0a10f7c4
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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"))),
|
||||
},
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user