Fix discounted positions in 1C exports
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
admin i 2026-09-16 14:43:12 +03:00
parent 52f957376d
commit 2c866e2ada
2 changed files with 197 additions and 35 deletions

View File

@ -4,6 +4,7 @@
"""
from lxml import etree
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, List, Optional, Tuple
import logging
@ -745,15 +746,46 @@ class XML1CGenerator:
def _format_money_1c(self, amount: int) -> str:
"""
Форматирование денежной суммы для 1С (копейки -> рубли без копеек)
Форматирование денежной суммы для 1С (копейки -> рубли)
Args:
amount: Сумма в копейках
Returns:
Сумма в рублях (целое число)
Сумма в рублях с копейками, если они есть
"""
return str(int(amount / 100))
rubles = Decimal(str(amount)) / 100
return str(int(rubles)) if rubles == int(rubles) else f"{rubles:.2f}"
@staticmethod
def _position_amounts(pos: Dict) -> Dict[str, Decimal]:
"""Суммы позиции в рублях с учетом процентной скидки МойСклад."""
price = Decimal(str(pos.get("price", 0))) / 100
quantity = Decimal(str(pos.get("quantity", 0)))
discount = Decimal(str(pos.get("discount") or 0))
vat_rate = Decimal(str(pos.get("vat", 20)))
discounted_price = price * (100 - discount) / 100
gross_total = (price * quantity).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
total = (discounted_price * quantity).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
vat_amount = (
(total * vat_rate / (100 + vat_rate)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
) if vat_rate else Decimal("0.00")
)
return {
"price": price,
"discounted_price": discounted_price,
"gross_total": gross_total,
"total": total,
"discount": discount,
"discount_amount": gross_total - total,
"vat_amount": vat_amount,
}
def _format_money_with_decimals(self, amount: float) -> str:
"""
@ -1158,13 +1190,12 @@ class XML1CGenerator:
quantity = pos.get("quantity", 0)
etree.SubElement(row_elem, "Количество").text = str(quantity)
# Цена и суммы (в рублях с копейками)
price = pos.get("price", 0) / 100 # Конвертируем из копеек
total = price * quantity
# Для товаров схема 1С передает скидку отдельными полями.
amounts = self._position_amounts(pos)
# Форматируем с 2 знаками после запятой (НЕ округляем до целых!)
etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{price:.2f}"
etree.SubElement(row_elem, "Сумма").text = f"{amounts['gross_total']:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{amounts['price']:.2f}"
# СтавкаНДС
vat_rate = pos.get("vat", 20)
@ -1188,8 +1219,12 @@ class XML1CGenerator:
).text = RUSSIA_COUNTRY_NAME
# СуммаНДС (извлекаем из суммы)
vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(vat_amount)
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(amounts["vat_amount"])
if amounts["discount"]:
etree.SubElement(row_elem, "ВидСкидки").text = "НаТовар"
etree.SubElement(row_elem, "ПроцентСкидки").text = f"{amounts['discount']:.2f}"
etree.SubElement(row_elem, "СуммаСкидки").text = f"{amounts['discount_amount']:.2f}"
etree.SubElement(row_elem, "СуммаСУчетомСкидки").text = f"{amounts['total']:.2f}"
# ТипЗапасов для товаров
etree.SubElement(
@ -1239,13 +1274,12 @@ class XML1CGenerator:
quantity = pos.get("quantity", 0)
etree.SubElement(row_elem, "Количество").text = str(quantity)
# Цена и суммы (в рублях с копейками)
price = pos.get("price", 0) / 100 # Конвертируем из копеек
total = price * quantity
# В секции услуг 1С нет полей скидки: передаем цену и сумму после скидки.
amounts = self._position_amounts(pos)
# Форматируем с 2 знаками после запятой
etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{price:.2f}"
etree.SubElement(row_elem, "Сумма").text = f"{amounts['total']:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{amounts['discounted_price']:.2f}"
# СтавкаНДС
vat_rate = pos.get("vat", 20)
@ -1268,8 +1302,7 @@ class XML1CGenerator:
).text = RUSSIA_COUNTRY_NAME
# СуммаНДС (извлекаем из суммы)
vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(vat_amount)
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(amounts["vat_amount"])
# Содержание (опционально)
etree.SubElement(row_elem, "Содержание").text = product_name
@ -1884,11 +1917,10 @@ class XML1CGenerator:
quantity = pos.get("quantity", 0)
etree.SubElement(row_elem, "Количество").text = str(quantity)
price = pos.get("price", 0) / 100
total = price * quantity
amounts = self._position_amounts(pos)
etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{price:.2f}"
etree.SubElement(row_elem, "Сумма").text = f"{amounts['gross_total']:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{amounts['price']:.2f}"
vat_rate = pos.get("vat", 20)
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
@ -1910,13 +1942,14 @@ class XML1CGenerator:
"Наименование"
).text = RUSSIA_COUNTRY_NAME
vat_amount = (
total / (1 + vat_rate / 100) * (vat_rate / 100)
if vat_rate else 0
)
etree.SubElement(
row_elem, "СуммаНДС"
).text = self._format_money_with_decimals(vat_amount)
).text = self._format_money_with_decimals(amounts["vat_amount"])
if amounts["discount"]:
etree.SubElement(row_elem, "ВидСкидки").text = "НаТовар"
etree.SubElement(row_elem, "ПроцентСкидки").text = f"{amounts['discount']:.2f}"
etree.SubElement(row_elem, "СуммаСкидки").text = f"{amounts['discount_amount']:.2f}"
etree.SubElement(row_elem, "СуммаСУчетомСкидки").text = f"{amounts['total']:.2f}"
# ТипЗапасов для товаров
etree.SubElement(
@ -1999,11 +2032,10 @@ class XML1CGenerator:
quantity = pos.get("quantity", 0)
etree.SubElement(row_elem, "Количество").text = str(quantity)
price = pos.get("price", 0) / 100
total = price * quantity
amounts = self._position_amounts(pos)
etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{price:.2f}"
etree.SubElement(row_elem, "Сумма").text = f"{amounts['total']:.2f}"
etree.SubElement(row_elem, "Цена").text = f"{amounts['discounted_price']:.2f}"
vat_rate = pos.get("vat", 20)
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
@ -2025,13 +2057,9 @@ class XML1CGenerator:
"Наименование"
).text = RUSSIA_COUNTRY_NAME
vat_amount = (
total / (1 + vat_rate / 100) * (vat_rate / 100)
if vat_rate else 0
)
etree.SubElement(
row_elem, "СуммаНДС"
).text = self._format_money_with_decimals(vat_amount)
).text = self._format_money_with_decimals(amounts["vat_amount"])
# Счета учета для услуг (БЕЗ счета 43)
if settings.include_accounting_accounts:

View File

@ -0,0 +1,134 @@
"""Regression checks for discounted MoySklad positions in 1C exports."""
import sys
import types
import unittest
from decimal import Decimal
from unittest.mock import patch
from lxml import etree
with patch.dict(
sys.modules,
{"config": types.SimpleNamespace(
settings=types.SimpleNamespace(include_accounting_accounts=False)
)},
):
from modules.xml_generator import XML1CGenerator
class DiscountExportTests(unittest.TestCase):
def setUp(self):
self.generator = XML1CGenerator()
@staticmethod
def order_data(positions, sum_kopecks):
return {
"order": {
"id": "example",
"name": "example",
"moment": "2026-09-15 09:00:00",
"sum": sum_kopecks,
},
"organization": {},
"counterparty": {},
"positions": positions,
}
@staticmethod
def values(xml):
root = etree.fromstring(xml.encode("utf-8"))
document = root.xpath('//*[local-name()="Документ.РеализацияТоваровУслуг"]')[0]
header = document.xpath('./*[local-name()="Сумма"]/text()')[0]
rows = document.xpath(
'./*[local-name()="Товары" or local-name()="Услуги"]'
'/*[local-name()="Строка"]'
)
return header, [
{
name: (row.xpath('./*[local-name()=$name]/text()', name=name) or [None])[0]
for name in (
"Цена", "Сумма", "СуммаНДС", "ВидСкидки", "ПроцентСкидки",
"СуммаСкидки", "СуммаСУчетомСкидки",
)
}
for row in rows
]
def test_discount_applies_to_goods_and_services_in_both_export_modes(self):
data = self.order_data([
{
"quantity": 2, "price": 10000, "discount": 10, "vat": 20,
"assortment": {"name": "Product", "meta": {"type": "product"}},
},
{
"quantity": 1, "price": 10000, "discount": 10, "vat": 20,
"assortment": {"name": "Service", "meta": {"type": "service"}},
},
], 27000)
for xml in (
self.generator.generate_realization(data),
self.generator.generate_batch_realization([data]),
):
with self.subTest(export=xml[:50]):
header, rows = self.values(xml)
self.assertEqual(header, "270")
self.assertEqual(
[(r["Цена"], r["Сумма"], r["СуммаНДС"]) for r in rows],
[("100.00", "200.00", "30.00"),
("90.00", "90.00", "15.00")],
)
self.assertEqual(rows[0]["ВидСкидки"], "НаТовар")
self.assertEqual(rows[0]["ПроцентСкидки"], "10.00")
self.assertEqual(rows[0]["СуммаСкидки"], "20.00")
self.assertEqual(rows[0]["СуммаСУчетомСкидки"], "180.00")
self.assertEqual(
Decimal(rows[0]["СуммаСУчетомСкидки"]) + Decimal(rows[1]["Сумма"]),
Decimal(header),
)
def test_fractional_discount_preserves_kopecks(self):
data = self.order_data([{
"quantity": 2, "price": 10001, "discount": 12.5, "vat": 20,
"assortment": {"name": "Product", "meta": {"type": "product"}},
}], 17502)
header, rows = self.values(self.generator.generate_realization(data))
self.assertEqual(header, "175.02")
self.assertEqual(rows[0]["Цена"], "100.01")
self.assertEqual(rows[0]["Сумма"], "200.02")
self.assertEqual(rows[0]["ПроцентСкидки"], "12.50")
self.assertEqual(rows[0]["СуммаСкидки"], "25.00")
self.assertEqual(rows[0]["СуммаСУчетомСкидки"], "175.02")
self.assertEqual(rows[0]["СуммаНДС"], "29.17")
def test_position_without_discount_keeps_original_price(self):
data = self.order_data([{
"quantity": 1, "price": 10001, "vat": 20,
"assortment": {"name": "Product", "meta": {"type": "product"}},
}], 10001)
header, rows = self.values(self.generator.generate_realization(data))
self.assertEqual(header, "100.01")
self.assertEqual(rows[0]["Цена"], "100.01")
self.assertEqual(rows[0]["Сумма"], "100.01")
self.assertIsNone(rows[0]["ПроцентСкидки"])
def test_service_discount_uses_net_amount_with_two_decimal_price(self):
data = self.order_data([{
"quantity": 2, "price": 10001, "discount": 12.5, "vat": 20,
"assortment": {"name": "Service", "meta": {"type": "service"}},
}], 17502)
header, rows = self.values(self.generator.generate_realization(data))
self.assertEqual(header, "175.02")
self.assertEqual(rows[0]["Цена"], "87.51")
self.assertEqual(rows[0]["Сумма"], "175.02")
self.assertEqual(rows[0]["СуммаНДС"], "29.17")
self.assertIsNone(rows[0]["ПроцентСкидки"])
if __name__ == "__main__":
unittest.main()