From 2c866e2adafb53c7dbdbd339da09b412e12c4693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 16 Sep 2026 14:43:12 +0300 Subject: [PATCH] Fix discounted positions in 1C exports --- modules/xml_generator.py | 98 ++++++++++++++++--------- tests/discount_export_test.py | 134 ++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 tests/discount_export_test.py diff --git a/modules/xml_generator.py b/modules/xml_generator.py index 40fe8b6..5f12dee 100644 --- a/modules/xml_generator.py +++ b/modules/xml_generator.py @@ -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: diff --git a/tests/discount_export_test.py b/tests/discount_export_test.py new file mode 100644 index 0000000..53d59ba --- /dev/null +++ b/tests/discount_export_test.py @@ -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()