Fix discounted positions in 1C exports
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
parent
52f957376d
commit
2c866e2ada
@ -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:
|
||||
|
||||
134
tests/discount_export_test.py
Normal file
134
tests/discount_export_test.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user