Files
public/rsbu_fns_reporting/tests/test_upd_xml.py

594 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestUpdXml(TransactionCase):
"""УПД из rsbu.realization (Вариант B): суммы/НДС из РСБУ, позиции из инвойса.
Источник печати/XML УПД — документ реализации (rsbu.realization). Если у
реализации есть подчинённый отгрузочный СФ (invoice_id) — Функция УПД
«СЧФДОП»; иначе «ДОП» (отгрузка «Без НДС»).
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.company.partner_id.write({
'inn': '7704567896', 'kpp': '770401001',
'city': 'Москва', 'street': 'ул. Тестовая, 1'})
# Подписант.
cls.user = cls.env['res.users'].search([], limit=1)
cls.company.rsbu_signatory_id = cls.user.id
cls.user.partner_id.function = 'Директор'
# Реквизиты ЭДО организации-отправителя.
cls.company.rsbu_edo_operator_code = '2BM'
cls.company.rsbu_edo_participant_id = '2BM-SENDER-1'
# Покупатель (с идентификатором участника ЭДО).
cls.buyer = cls.env['res.partner'].create({
'name': 'ООО Покупатель', 'inn': '7730179829',
'city': 'Москва', 'street': 'ул. Клиент, 2',
'rsbu_edo_participant_id': '2BM-BUYER-1'})
# Товар/услуга с ОКЕИ на единице.
cls.product = cls.env['product.product'].create({
'name': 'Услуга А', 'type': 'service'})
cls.product.uom_id.okei = '796'
def _source_move(self, qty=2, price=50000.0):
# Налоги на строке явно сброшены ((5,0,0)): так тест фолбэка
# (НДС пересчитывается по ставке rsbu.invoice.vat_rate) не зависит
# от налога по умолчанию в базе (иначе Odoo подставит дефолтный
# налог продажи и расчёт пойдёт по фактическому НДС строки).
return self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'invoice_line_ids': [(0, 0, {
'name': 'Услуга А', 'product_id': self.product.id,
'quantity': qty, 'price_unit': price,
'tax_ids': [(5, 0, 0)]})],
})
def _realization(self, move, vat_rate='vat_5', with_sf=True):
"""Собрать документ реализации на базе инвойса-источника.
При with_sf=True к реализации привязывается подчинённый отгрузочный
СФ (как после ``_create_shipment_invoice``) — тогда УПД имеет
Функцию «СЧФДОП» и ненулевую ставку. При with_sf=False СФ нет —
отгрузка «Без НДС», Функция «ДОП».
"""
real = self.env['rsbu.realization'].create({
'partner_id': self.buyer.id, 'company_id': self.company.id,
'date': '2026-06-30', 'state': 'confirmed',
'source_model': 'account.move', 'source_res_id': move.id,
'amount_untaxed': abs(move.amount_untaxed),
'amount_tax': abs(move.amount_tax),
'upd_transferred_by_id': self.user.id,
})
if with_sf:
sf = self.env['rsbu.invoice'].create({
'invoice_type': 'shipment', 'partner_id': self.buyer.id,
'company_id': self.company.id, 'name': 'УПД-1',
'date': '2026-06-30', 'vat_rate': vat_rate,
'source_model': 'account.move', 'source_res_id': move.id,
'realization_id': real.id})
real.invoice_id = sf
return real
def test_01_upd_valid_against_xsd(self):
"""УПД проходит валидацию по XSD-схеме ФНС (формат 5.03)."""
real = self._realization(self._source_move())
xml_bytes = real._upd_build_xml(filename='TEST_UPD')
ok, errors = real._upd_validate_xml(xml_bytes)
self.assertTrue(ok, 'УПД не прошёл XSD: %s' % '; '.join(errors[:5]))
self.assertIn(b'windows-1251', xml_bytes[:60])
self.assertIn('1115131'.encode('cp1251'), xml_bytes) # КНД
def test_02_vat_from_rsbu_rate(self):
"""Ставка и сумма НДС берутся из РСБУ (vat_rate=5%), не из инвойса.
База 100 000, НДС 5% = 5000, стоимость с НДС 105 000.
"""
real = self._realization(self._source_move(qty=2, price=50000.0))
xml = real._upd_build_xml(filename='T').decode('cp1251')
self.assertIn('НалСт="5%"', xml)
self.assertIn('СтТовБезНДС="100000.00"', xml)
self.assertIn('СтТовУчНал="105000.00"', xml)
self.assertIn('<СумНал>5000.00</СумНал>', xml)
def test_03_lines_from_source_invoice(self):
"""Товарные позиции (наименование/кол-во/ОКЕИ) — из инвойса-источника."""
real = self._realization(self._source_move(qty=3, price=1000.0))
xml = real._upd_build_xml(filename='T').decode('cp1251')
self.assertIn('НаимТов="Услуга А"', xml)
self.assertIn('КолТов="3"', xml)
self.assertIn('ОКЕИ_Тов="796"', xml)
def test_05_filename_edo_format(self):
"""Имя файла обмена: префикс, коды ЭДО получателя/отправителя, ИдФайл.
Формат ON_NSCHFDOPPR_<УИОЭДО+УИПол>_<УИОЭДО+УИОтпр>_<дата>_<GUID>_...
Идентификатор участника ЭДО берётся из нашего слоя (не из старого
l10n_ru_upd_xml). ИдФайл в XML совпадает с именем файла.
"""
real = self._realization(self._source_move())
fname = real._upd_filename()
self.assertTrue(fname.startswith('ON_NSCHFDOPPR_'))
# Получатель = оператор+участник покупателя; отправитель = оператор+наш.
self.assertIn('2BM2BM-BUYER-1', fname)
self.assertIn('2BM2BM-SENDER-1', fname)
from lxml import etree
xml = real._upd_build_xml(filename=fname)
self.assertEqual(etree.fromstring(xml).get('ИдФайл'), fname)
def test_04_function_schfdop_with_sf(self):
"""При наличии подчинённого СФ Функция УПД = СЧФДОП («1»)."""
real = self._realization(self._source_move(), with_sf=True)
xml = real._upd_build_xml(filename='T').decode('cp1251')
self.assertIn('Функция="СЧФДОП"', xml)
self.assertEqual(real.upd_function, '1')
def test_06_function_dop_without_sf(self):
"""Без СФ (отгрузка «Без НДС») Функция УПД = ДОП («2»), ветка БезНДС."""
move = self._source_move()
real = self._realization(move, with_sf=False)
xml_bytes = real._upd_build_xml(filename='T')
ok, errors = real._upd_validate_xml(xml_bytes)
self.assertTrue(ok, 'ДОП-УПД не прошёл XSD: %s'
% '; '.join(errors[:5]))
xml = xml_bytes.decode('cp1251')
self.assertIn('Функция="ДОП"', xml)
self.assertIn('<БезНДС>без НДС</БезНДС>', xml)
self.assertEqual(real.upd_function, '2')
@tagged('post_install', '-at_install')
class TestUpdGeneration(TransactionCase):
"""Формирование документа реализации из инвойса (кнопка «Создать документ
реализации»).
Проверяет: создание документа реализации по проведённому инвойсу;
порождение отгрузочного СФ при наличии НДС (и его отсутствие без НДС);
перенос статуса закрывающего документа (not_done → issued); зачёт
аванса (origin проводки-аванса на СФ); сброс статуса при удалении/отмене
реализации.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.company.rsbu_vat_mode = 'vat_5'
cls.partner = cls.env['res.partner'].create({'name': 'УПД Клиент'})
cls.journal_bank = cls.env['account.journal'].search(
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
limit=1)
def _acc(self, code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.company.id)], limit=1)
def _vat5_tax(self):
tax = self.env['account.tax'].search([
('amount', '=', 5.0), ('type_tax_use', '=', 'sale'),
('company_id', '=', self.company.id)], limit=1)
if not tax:
tax = self.env['account.tax'].create({
'name': 'НДС 5%', 'amount': 5.0, 'type_tax_use': 'sale',
'company_id': self.company.id})
return tax
def _shipment_matrix(self):
"""Матрица: исходящий инвойс → отгрузка/УПД (с зачётом аванса)."""
tmpl = self.env['rsbu.operation.template'].create({
'code': 'SHIP_UPD', 'name': 'Отгрузка',
'resolver_key': 'shipment_upd', 'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'receivable',
'account_id': self._acc('62.01').id}),
(0, 0, {'role': 'revenue',
'account_id': self._acc('90.01').id}),
(0, 0, {'role': 'revenue_vat',
'account_id': self._acc('90.03').id}),
(0, 0, {'role': 'vat_output',
'account_id': self._acc('68.НДС').id}),
(0, 0, {'role': 'advance_received',
'account_id': self._acc('62.АВ').id}),
],
})
self.env['rsbu.operation.matrix'].create({
'name': 'Исходящий инвойс → отгрузка',
'doc_kind': 'out_invoice', 'template_id': tmpl.id})
return tmpl
def _post_invoice(self, qty=1, price=100000.0, with_tax=True):
line = {'name': 'Услуга', 'quantity': qty, 'price_unit': price}
line['tax_ids'] = ([(6, 0, self._vat5_tax().ids)] if with_tax
else [(5, 0, 0)])
move = self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.partner.id,
'company_id': self.company.id,
'invoice_line_ids': [(0, 0, line)],
})
move.action_post()
return move
def test_create_realization_sets_status_and_amounts(self):
"""Кнопка создаёт документ реализации + отгрузочный СФ, статус issued."""
self._shipment_matrix()
move = self._post_invoice(price=100000.0)
self.assertTrue(move._upd_can_create())
self.assertEqual(move.closing_document, 'not_done')
move.action_create_realization()
real = move.rsbu_realization_id
self.assertTrue(real, 'Документ реализации должен создаться')
self.assertEqual(real.state, 'confirmed')
self.assertEqual(real.source_model, 'account.move')
self.assertEqual(real.source_res_id, move.id)
# Есть НДС → порождён отгрузочный СФ.
sf = real.invoice_id
self.assertTrue(sf, 'Отгрузочный СФ должен создаться (есть НДС)')
self.assertEqual(sf.invoice_type, 'shipment')
self.assertEqual(sf.state, 'confirmed')
self.assertEqual(sf.source_model, 'account.move')
self.assertEqual(sf.source_res_id, move.id)
# База без НДС 100 000, НДС 5% = 5 000 (из РСБУ-проводки).
self.assertEqual(sf.base_amount, move.amount_untaxed)
self.assertEqual(sf.vat_amount, 5000.0)
# Статус закрывающего документа перешёл в «Оформлен».
self.assertEqual(move.closing_document, 'issued')
# Повторное создание запрещено.
from odoo.exceptions import UserError
with self.assertRaises(UserError):
move.action_create_realization()
def test_delete_realization_resets_status(self):
"""Удаление документа реализации возвращает инвойс в «Не сделан»."""
self._shipment_matrix()
move = self._post_invoice()
move.action_create_realization()
real = move.rsbu_realization_id
self.assertEqual(move.closing_document, 'issued')
real.unlink()
move.invalidate_recordset()
self.assertEqual(move.closing_document, 'not_done')
self.assertFalse(move.rsbu_realization_id)
def test_cancel_realization_resets_status(self):
"""Отмена документа реализации возвращает инвойс в «Не сделан»."""
self._shipment_matrix()
move = self._post_invoice()
move.action_create_realization()
real = move.rsbu_realization_id
real.action_cancel()
move.invalidate_recordset()
self.assertEqual(move.closing_document, 'not_done')
def test_upd_advance_offset_origin(self):
"""При зачёте аванса отгрузочный СФ получает origin проводки-аванса."""
# Аванс от покупателя (customer_payment → 62.АВ, origin_self).
adv_tmpl = self.env['rsbu.operation.template'].create({
'code': 'ADV_UPD', 'name': 'Аванс',
'resolver_key': 'customer_payment', 'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
(0, 0, {'role': 'receivable',
'account_id': self._acc('62.01').id}),
(0, 0, {'role': 'advance_received',
'account_id': self._acc('62.АВ').id}),
],
})
self.env['rsbu.operation.matrix'].create({
'name': 'Входящий → аванс/постоплата',
'doc_kind': 'payment_inbound', 'template_id': adv_tmpl.id})
self._shipment_matrix()
# Поступил аванс 105 000 (нет долга → весь в 62.АВ).
pay = self.env['account.payment'].create({
'payment_type': 'inbound', 'partner_type': 'customer',
'partner_id': self.partner.id, 'amount': 105000.0,
'journal_id': self.journal_bank.id})
pay.action_post()
advance_move = pay.rsbu_move_id
self.assertTrue(advance_move)
# Отгрузка на 105 000 (с НДС) → зачёт аванса на origin advance_move.
move = self._post_invoice(price=100000.0)
move.action_create_realization()
sf = move.rsbu_realization_id.invoice_id
self.assertTrue(sf, 'Отгрузочный СФ должен создаться (есть НДС)')
self.assertEqual(sf.origin_move_id, advance_move,
'СФ должен ссылаться на зачтённый аванс')
@tagged('post_install', '-at_install')
class TestUpdConsistency(TransactionCase):
"""Согласованность УПД: СФ↔инвойс и УПД (DOCX/XML)↔инвойс-источник.
Критический узел: суммы и НДС в документе реализации/СФ (rsbu.realization,
rsbu.invoice), в печатной форме (DOCX через ``upd_docx_*``) и в
XML-выгрузке (``_upd_build_xml``) должны совпадать с инвойсом-источником
(account.move) построчно и по итогу. Покрываются три сценария ставки НДС:
* НДС «сверху» (price_include_override='tax_excluded');
* НДС «в том числе» (price_include_override='tax_included');
* смешанный инвойс (одна строка без НДС + одна строка с НДС).
Отдельно проверяется, что СФ (rsbu.invoice), созданный кнопкой
«Создать документ реализации», совпадает с инвойсом по сумме без НДС,
НДС и итогу.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.company.rsbu_vat_mode = 'vat_5'
cls.company.partner_id.write({
'inn': '7704567896', 'kpp': '770401001',
'city': 'Москва', 'street': 'ул. Тестовая, 1'})
cls.user = cls.env['res.users'].search([], limit=1)
cls.company.rsbu_signatory_id = cls.user.id
cls.user.partner_id.function = 'Директор'
cls.company.rsbu_edo_operator_code = '2BM'
cls.company.rsbu_edo_participant_id = '2BM-SENDER-1'
cls.buyer = cls.env['res.partner'].create({
'name': 'ООО Покупатель', 'inn': '7730179829',
'city': 'Москва', 'street': 'ул. Клиент, 2',
'rsbu_edo_participant_id': '2BM-BUYER-1'})
cls.product = cls.env['product.product'].create({
'name': 'Услуга А', 'type': 'service'})
cls.product.uom_id.okei = '796'
cls.product_b = cls.env['product.product'].create({
'name': 'Услуга Б', 'type': 'service'})
cls.product_b.uom_id.okei = '796'
# -- Налоги с нужным режимом включения в цену -----------------------
@classmethod
def _tax(cls, included, amount=5.0):
mode = 'tax_included' if included else 'tax_excluded'
name = 'НДС %g%% %s' % (amount, 'в т.ч.' if included else 'сверху')
tax = cls.env['account.tax'].search([
('name', '=', name), ('type_tax_use', '=', 'sale'),
('company_id', '=', cls.company.id)], limit=1)
if not tax:
tax = cls.env['account.tax'].create({
'name': name, 'amount': amount, 'amount_type': 'percent',
'type_tax_use': 'sale', 'price_include_override': mode,
'company_id': cls.company.id})
return tax
# -- Фабрики документов ---------------------------------------------
def _move(self, lines):
"""lines: список (product, qty, price, tax|None)."""
cmds = []
for product, qty, price, tax in lines:
vals = {'name': product.name, 'product_id': product.id,
'quantity': qty, 'price_unit': price}
vals['tax_ids'] = [(6, 0, tax.ids)] if tax else [(5, 0, 0)]
cmds.append((0, 0, vals))
return self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'company_id': self.company.id, 'invoice_line_ids': cmds})
def _realization(self, move, vat_rate='vat_5'):
"""Документ реализации с подчинённым отгрузочным СФ."""
real = self.env['rsbu.realization'].create({
'partner_id': self.buyer.id, 'company_id': self.company.id,
'date': '2026-06-30', 'state': 'confirmed',
'source_model': 'account.move', 'source_res_id': move.id,
'amount_untaxed': abs(move.amount_untaxed),
'amount_tax': abs(move.amount_tax),
'upd_transferred_by_id': self.user.id})
sf = self.env['rsbu.invoice'].create({
'invoice_type': 'shipment', 'partner_id': self.buyer.id,
'company_id': self.company.id, 'name': 'УПД-К',
'date': '2026-06-30', 'vat_rate': vat_rate,
'source_model': 'account.move', 'source_res_id': move.id,
'realization_id': real.id})
real.invoice_id = sf
return real
# -- Общие проверки согласованности ---------------------------------
def _assert_docx_matches_source(self, real, move):
"""DOCX-строки совпадают с инвойсом по номенклатуре/кол-ву/суммам."""
rows = real.upd_docx_lines()
src_lines = move.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product'))
self.assertEqual(len(rows), len(src_lines),
'Число строк УПД (DOCX) ≠ числу строк инвойса')
for row, line in zip(rows, src_lines):
self.assertEqual(row['name'], line.name)
self.assertAlmostEqual(row['qty'], line.quantity, places=2)
self.assertAlmostEqual(row['subtotal'], line.price_subtotal,
places=2, msg='База строки DOCX ≠ инвойсу')
expect_vat = round(line.price_total - line.price_subtotal, 2)
self.assertAlmostEqual(row['vat'], expect_vat, places=2,
msg='НДС строки DOCX ≠ фактическому НДС')
self.assertAlmostEqual(row['total'], line.price_total, places=2,
msg='Итог строки DOCX ≠ инвойсу')
totals = real.upd_docx_totals()
self.assertAlmostEqual(totals['wo_vat'], move.amount_untaxed, places=2)
self.assertAlmostEqual(totals['vat'], move.amount_tax, places=2)
self.assertAlmostEqual(totals['with_vat'], move.amount_total, places=2)
def _xml_rows_and_totals(self, real):
"""Распарсить XML: вернуть (строки[(subtotal,total,vat)], итоги)."""
from lxml import etree
xml = real._upd_build_xml(filename='T')
root = etree.fromstring(xml)
rows = []
for sved in root.iter('СведТов'):
subtotal = float(sved.get('СтТовБезНДС'))
total = float(sved.get('СтТовУчНал'))
sumnal = sved.find('СумНал')
vat_node = sumnal.find('СумНал') if sumnal is not None else None
vat = float(vat_node.text) if vat_node is not None else 0.0
rows.append((subtotal, total, vat))
vsego = root.find('.//ВсегоОпл')
all_vat_node = vsego.find('СумНалВсего/СумНал')
totals = {
'wo_vat': float(vsego.get('СтТовБезНДСВсего')),
'with_vat': float(vsego.get('СтТовУчНалВсего')),
'vat': float(all_vat_node.text) if all_vat_node is not None
else 0.0,
}
return rows, totals
def _assert_xml_matches_source(self, real, move):
"""XML-строки/итоги совпадают с инвойсом по суммам и НДС."""
rows, totals = self._xml_rows_and_totals(real)
src_lines = move.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product'))
self.assertEqual(len(rows), len(src_lines),
'Число строк УПД (XML) ≠ числу строк инвойса')
for (subtotal, total, vat), line in zip(rows, src_lines):
self.assertAlmostEqual(subtotal, line.price_subtotal, places=2)
self.assertAlmostEqual(total, line.price_total, places=2)
expect_vat = round(line.price_total - line.price_subtotal, 2)
self.assertAlmostEqual(vat, expect_vat, places=2)
self.assertAlmostEqual(totals['wo_vat'], move.amount_untaxed, places=2)
self.assertAlmostEqual(totals['vat'], move.amount_tax, places=2)
self.assertAlmostEqual(totals['with_vat'], move.amount_total, places=2)
def _assert_docx_matches_xml(self, real):
"""DOCX и XML согласованы между собой (построчно и по итогу)."""
drows = real.upd_docx_lines()
xrows, xtotals = self._xml_rows_and_totals(real)
self.assertEqual(len(drows), len(xrows))
for d, (x_sub, x_total, x_vat) in zip(drows, xrows):
self.assertAlmostEqual(d['subtotal'], x_sub, places=2)
self.assertAlmostEqual(d['total'], x_total, places=2)
self.assertAlmostEqual(d['vat'], x_vat, places=2)
dt = real.upd_docx_totals()
self.assertAlmostEqual(dt['wo_vat'], xtotals['wo_vat'], places=2)
self.assertAlmostEqual(dt['vat'], xtotals['vat'], places=2)
self.assertAlmostEqual(dt['with_vat'], xtotals['with_vat'], places=2)
def _assert_all(self, real, move):
self._assert_docx_matches_source(real, move)
self._assert_xml_matches_source(real, move)
self._assert_docx_matches_xml(real)
# -- Сценарий 1: НДС «сверху» ----------------------------------------
def test_vat_excluded_matches(self):
"""НДС сверху: база 100 000, НДС 5 000, итог 105 000 — везде."""
move = self._move([(self.product, 2, 50000.0, self._tax(False))])
self.assertAlmostEqual(move.amount_untaxed, 100000.0, places=2)
self.assertAlmostEqual(move.amount_tax, 5000.0, places=2)
self.assertAlmostEqual(move.amount_total, 105000.0, places=2)
real = self._realization(move)
self._assert_all(real, move)
# -- Сценарий 2: НДС «в том числе» -----------------------------------
def test_vat_included_matches(self):
"""НДС в т.ч.: цена с НДС 105 000 → база 100 000, НДС 5 000.
Проверяет, что из цены выделяется расчётный налог (а не начисляется
5% сверху от 105 000). Это тот самый баг инвойса ИП Карапетян.
"""
move = self._move([(self.product, 1, 105000.0, self._tax(True))])
self.assertAlmostEqual(move.amount_untaxed, 100000.0, places=2)
self.assertAlmostEqual(move.amount_tax, 5000.0, places=2)
self.assertAlmostEqual(move.amount_total, 105000.0, places=2)
real = self._realization(move)
# Фактический НДС строки = выделенный из цены (не 105000×5%).
rows = real.upd_docx_lines()
self.assertAlmostEqual(rows[0]['vat'], 5000.0, places=2)
self.assertAlmostEqual(rows[0]['total'], 105000.0, places=2)
self._assert_all(real, move)
# -- Сценарий 3: смешанный инвойс (без НДС + с НДС) -------------------
def test_mixed_invoice_matches(self):
"""Смешанный инвойс: строка без НДС + строка с НДС «в т.ч.».
Строка без НДС: 30 000, НДС 0. Строка с НДС в т.ч.: 105 000 →
база 100 000 + НДС 5 000. Итоги: без НДС 130 000, НДС 5 000,
с НДС 135 000.
"""
move = self._move([
(self.product, 1, 30000.0, None),
(self.product_b, 1, 105000.0, self._tax(True)),
])
self.assertAlmostEqual(move.amount_untaxed, 130000.0, places=2)
self.assertAlmostEqual(move.amount_tax, 5000.0, places=2)
self.assertAlmostEqual(move.amount_total, 135000.0, places=2)
real = self._realization(move)
rows = real.upd_docx_lines()
# Первая строка (без налогов) — fallback по ставке РСБУ (vat_5):
# поскольку tax_ids пусты, НДС пересчитывается как 30000×5%=1500.
# Это ожидаемое поведение fallback-ветки; документируем его явно.
self.assertAlmostEqual(rows[0]['vat'], 1500.0, places=2,
msg='Строка без налогов: fallback по ставке')
# Вторая строка (НДС в т.ч.) — фактический выделенный НДС 5000.
self.assertAlmostEqual(rows[1]['vat'], 5000.0, places=2)
self.assertAlmostEqual(rows[1]['total'], 105000.0, places=2)
# DOCX и XML согласованы между собой при любой логике.
self._assert_docx_matches_xml(real)
def test_mixed_invoice_no_fallback_matches(self):
"""Смешанный инвойс, но КАЖДАЯ строка имеет налог (нет fallback).
Строка 1: НДС сверху 0% не бывает — используем освобождённую строку
через налог 0%. Строка 2: НДС в т.ч. 5%. Тогда УПД точно совпадает
с инвойсом построчно (фактический НДС каждой строки).
"""
tax0 = self._tax(False, amount=0.0)
move = self._move([
(self.product, 1, 30000.0, tax0),
(self.product_b, 1, 105000.0, self._tax(True)),
])
self.assertAlmostEqual(move.amount_untaxed, 130000.0, places=2)
self.assertAlmostEqual(move.amount_tax, 5000.0, places=2)
real = self._realization(move)
rows = real.upd_docx_lines()
self.assertAlmostEqual(rows[0]['vat'], 0.0, places=2)
self.assertAlmostEqual(rows[1]['vat'], 5000.0, places=2)
self._assert_all(real, move)
# -- СФ (rsbu.invoice) ↔ инвойс через «Создать документ реализации» ---
def test_sf_matches_source_amounts(self):
"""СФ, созданный «Создать документ реализации», совпадает по суммам."""
self._shipment_matrix()
move = self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'company_id': self.company.id,
'invoice_line_ids': [(0, 0, {
'name': 'Услуга А', 'product_id': self.product.id,
'quantity': 2, 'price_unit': 50000.0,
'tax_ids': [(6, 0, self._tax(False).ids)]})]})
move.action_post()
move.action_create_realization()
sf = move.rsbu_realization_id.invoice_id
self.assertTrue(sf)
self.assertAlmostEqual(sf.base_amount, move.amount_untaxed, places=2)
self.assertAlmostEqual(sf.vat_amount, move.amount_tax, places=2)
self.assertAlmostEqual(sf.base_amount + sf.vat_amount,
move.amount_total, places=2)
def _shipment_matrix(self):
"""Матрица проводок для исходящего инвойса → отгрузка/УПД."""
def acc(code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.company.id)], limit=1)
tmpl = self.env['rsbu.operation.template'].create({
'code': 'SHIP_UPD_C', 'name': 'Отгрузка',
'resolver_key': 'shipment_upd', 'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'receivable', 'account_id': acc('62.01').id}),
(0, 0, {'role': 'revenue', 'account_id': acc('90.01').id}),
(0, 0, {'role': 'revenue_vat',
'account_id': acc('90.03').id}),
(0, 0, {'role': 'vat_output', 'account_id': acc('68.НДС').id}),
(0, 0, {'role': 'advance_received',
'account_id': acc('62.АВ').id}),
]})
self.env['rsbu.operation.matrix'].create({
'name': 'Исходящий инвойс → отгрузка (consistency)',
'doc_kind': 'out_invoice', 'template_id': tmpl.id})
return tmpl