Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
8
rsbu_fns_reporting/tests/__init__.py
Normal file
8
rsbu_fns_reporting/tests/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import test_usn_declaration
|
||||
from . import test_upd_xml
|
||||
from . import test_nds_declaration
|
||||
from . import test_rsv_declaration
|
||||
from . import test_enp_notification
|
||||
from . import test_ndfl_report
|
||||
from . import test_pers_info
|
||||
from . import test_efs1_szvtd
|
||||
208
rsbu_fns_reporting/tests/test_efs1_szvtd.py
Normal file
208
rsbu_fns_reporting/tests/test_efs1_szvtd.py
Normal file
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ЕФС-1 подраздел 1.1 (СЗВ-ТД): автозаполнение, построение XML, XSD.
|
||||
|
||||
Сверка с живыми образцами 1С (ref_1c/new), принятыми СФР:
|
||||
* ПФР_..._20260401 — ПРИЕМ (вид 1);
|
||||
* ПФР_..._20260803 — УВОЛЬНЕНИЕ (вид 5, с основанием увольнения).
|
||||
"""
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
NS_EFS1 = 'http://пф.рф/ЕФС-1/2024-01-01'
|
||||
NS_EFS = 'http://пф.рф/ВС/ЕФС/2024-01-01'
|
||||
NS_UT2 = 'http://пф.рф/УТ/2017-08-21'
|
||||
NS_AF5 = 'http://пф.рф/АФ/2018-12-07'
|
||||
|
||||
|
||||
def _q(ns, tag):
|
||||
return '{%s}%s' % (ns, tag)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestEfs1Szvtd(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_sfr_reg_number': '060-054-063199',
|
||||
'rsbu_okved': '62.01',
|
||||
'rsbu_ogrn': '1245000059622',
|
||||
'rsbu_signatory_title': 'ГЕНЕРАЛЬНЫЙ ДИРЕКТОР',
|
||||
})
|
||||
cls.company.partner_id.write({
|
||||
'inn': '5050164162', 'kpp': '505001001'})
|
||||
user = cls.env['res.users'].search([], limit=1)
|
||||
user.partner_id.write({'name': 'Коробков Сергей Васильевич'})
|
||||
cls.company.rsbu_signatory_id = user.id
|
||||
|
||||
cls.emp_hire = cls.env['hr.employee'].create({
|
||||
'name': 'Горский Алексей Алексеевич', 'company_id': cls.company.id,
|
||||
'rsbu_snils': '195-652-650 11', 'rsbu_inn': '780604730641',
|
||||
'rsbu_citizenship_code': '643', 'birthday': '2003-09-03'})
|
||||
cls.emp_fire = cls.env['hr.employee'].create({
|
||||
'name': 'Зимина Мария Михайловна', 'company_id': cls.company.id,
|
||||
'rsbu_snils': '171-732-575 70', 'rsbu_inn': '671405753608',
|
||||
'rsbu_citizenship_code': '643', 'birthday': '1997-08-11'})
|
||||
|
||||
cls.Event = cls.env['rsbu.hr.event']
|
||||
cls.Efs1 = cls.env['rsbu.efs1.szvtd']
|
||||
|
||||
def _hire_event(self):
|
||||
return self.Event.create({
|
||||
'company_id': self.company.id,
|
||||
'employee_id': self.emp_hire.id,
|
||||
'event_date': '2026-04-01',
|
||||
'kind': '1',
|
||||
'job_title': 'Младший менеджер проектов',
|
||||
'employment_type': '0',
|
||||
'department_name': 'Отдел разработки программного обеспечения',
|
||||
'func_code': 'ДИСТ',
|
||||
'okz_code': '2421.9',
|
||||
'basis_name': 'Приказ',
|
||||
'basis_date': '2026-04-01',
|
||||
'basis_number': '1',
|
||||
})
|
||||
|
||||
def _fire_event(self):
|
||||
return self.Event.create({
|
||||
'company_id': self.company.id,
|
||||
'employee_id': self.emp_fire.id,
|
||||
'event_date': '2026-08-03',
|
||||
'kind': '5',
|
||||
'job_title': 'Разработчик',
|
||||
'employment_type': '0',
|
||||
'work_kind': 'Разработка программного обеспечения',
|
||||
'department_name': 'Отдел разработки программного обеспечения',
|
||||
'okz_code': '2512.1',
|
||||
'dismissal_reason_code': 'п3ч1с77тк',
|
||||
'dismissal_document': 'Трудовой кодекс Российской Федерации',
|
||||
'dismissal_article': '77',
|
||||
'dismissal_part': '1',
|
||||
'dismissal_point': '3',
|
||||
'dismissal_reason':
|
||||
'Расторжение трудового договора по инициативе работника',
|
||||
'basis_name': 'Приказ',
|
||||
'basis_date': '2026-08-03',
|
||||
'basis_number': '1',
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def test_01_autofill_period(self):
|
||||
"""action_compute собирает draft-мероприятия за период."""
|
||||
self._hire_event()
|
||||
self._fire_event()
|
||||
doc = self.Efs1.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-04-01', 'date_to': '2026-08-31',
|
||||
'date_fill': '2026-08-31'})
|
||||
doc.action_compute()
|
||||
self.assertEqual(doc.state, 'computed')
|
||||
self.assertEqual(doc.event_count, 2)
|
||||
self.assertEqual(doc.employee_count, 2)
|
||||
|
||||
def test_02_hire_xml_valid_and_structure(self):
|
||||
"""XML приёма проходит XSD и совпадает по структуре с образцом."""
|
||||
self._hire_event()
|
||||
doc = self.Efs1.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-04-01', 'date_to': '2026-04-30',
|
||||
'date_fill': '2026-04-01'})
|
||||
doc.action_compute()
|
||||
xml_bytes = doc._efs1_build_xml(guid='311086e3-d18b-4794-a0ea-033f23c1edb2')
|
||||
ok, errors = doc._efs1_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'ЕФС-1 приём не прошёл XSD: %s'
|
||||
% '; '.join(errors[:5]))
|
||||
|
||||
root = ET.fromstring(xml_bytes)
|
||||
self.assertEqual(root.tag, _q(NS_EFS1, 'ЭДПФР'))
|
||||
efs1 = root.find(_q(NS_EFS1, 'ЕФС-1'))
|
||||
self.assertIsNotNone(efs1)
|
||||
strah = efs1.find(_q(NS_EFS1, 'Страхователь'))
|
||||
self.assertEqual(strah.find(_q(NS_EFS, 'РегНомер')).text,
|
||||
'060-054-063199')
|
||||
self.assertEqual(strah.find(_q(NS_UT2, 'ИНН')).text, '5050164162')
|
||||
self.assertEqual(strah.find(_q(NS_EFS, 'ОГРН')).text,
|
||||
'1245000059622')
|
||||
# ЗЛ → СЗВ-ТД → ТД → Мероприятие
|
||||
zl = efs1.find(_q(NS_EFS1, 'СЗВ')).find(_q(NS_EFS1, 'ЗЛ'))
|
||||
self.assertEqual(zl.find(_q(NS_UT2, 'СНИЛС')).text, '195-652-650 11')
|
||||
self.assertEqual(zl.find(_q(NS_EFS1, 'СтатусЗЛ')).text, 'ГРФ')
|
||||
self.assertEqual(zl.find(_q(NS_EFS1, 'Гражданство')).text, '643')
|
||||
td = zl.find(_q(NS_EFS1, 'СЗВ-ТД')).find(_q(NS_EFS1, 'ТД'))
|
||||
m = td.find(_q(NS_EFS1, 'Мероприятие'))
|
||||
self.assertEqual(m.find(_q(NS_EFS, 'Вид')).text, '1')
|
||||
self.assertEqual(m.find(_q(NS_EFS, 'КодВФпоОКЗ')).text, '2421.9')
|
||||
self.assertEqual(m.find(_q(NS_EFS, 'КодВФ')).text, 'ДИСТ')
|
||||
basis = m.find(_q(NS_EFS, 'Основание'))
|
||||
self.assertEqual(basis.find(_q(NS_EFS, 'Номер')).text, '1')
|
||||
# Руководитель + СлужебнаяИнформация
|
||||
ruk = efs1.find(_q(NS_EFS1, 'Руководитель'))
|
||||
self.assertEqual(ruk.find(_q(NS_UT2, 'Должность')).text,
|
||||
'ГЕНЕРАЛЬНЫЙ ДИРЕКТОР')
|
||||
svc = root.find(_q(NS_EFS1, 'СлужебнаяИнформация'))
|
||||
self.assertEqual(svc.find(_q(NS_AF5, 'GUID')).text,
|
||||
'311086e3-d18b-4794-a0ea-033f23c1edb2')
|
||||
|
||||
def test_03_fire_xml_valid_and_dismissal(self):
|
||||
"""XML увольнения проходит XSD, блок ОснованиеУвольнения заполнен."""
|
||||
self._fire_event()
|
||||
doc = self.Efs1.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-08-01', 'date_to': '2026-08-31',
|
||||
'date_fill': '2026-08-03'})
|
||||
doc.action_compute()
|
||||
xml_bytes = doc._efs1_build_xml()
|
||||
ok, errors = doc._efs1_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'ЕФС-1 увольнение не прошло XSD: %s'
|
||||
% '; '.join(errors[:5]))
|
||||
|
||||
root = ET.fromstring(xml_bytes)
|
||||
m = (root.find(_q(NS_EFS1, 'ЕФС-1'))
|
||||
.find(_q(NS_EFS1, 'СЗВ')).find(_q(NS_EFS1, 'ЗЛ'))
|
||||
.find(_q(NS_EFS1, 'СЗВ-ТД')).find(_q(NS_EFS1, 'ТД'))
|
||||
.find(_q(NS_EFS1, 'Мероприятие')))
|
||||
self.assertEqual(m.find(_q(NS_EFS, 'Вид')).text, '5')
|
||||
ou = m.find(_q(NS_EFS, 'ОснованиеУвольнения'))
|
||||
self.assertIsNotNone(ou)
|
||||
self.assertEqual(ou.find(_q(NS_EFS, 'КодПричиныУвольнения')).text,
|
||||
'п3ч1с77тк')
|
||||
self.assertEqual(ou.find(_q(NS_EFS, 'Статья')).text, '77')
|
||||
self.assertEqual(ou.find(_q(NS_EFS, 'Часть')).text, '1')
|
||||
self.assertEqual(ou.find(_q(NS_EFS, 'Пункт')).text, '3')
|
||||
self.assertEqual(m.find(_q(NS_EFS, 'Причина')).text,
|
||||
'Расторжение трудового договора по инициативе работника')
|
||||
|
||||
def test_04_multi_zl(self):
|
||||
"""Два сотрудника → два блока ЗЛ в одном документе."""
|
||||
self._hire_event()
|
||||
self._fire_event()
|
||||
doc = self.Efs1.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-04-01', 'date_to': '2026-08-31',
|
||||
'date_fill': '2026-08-31'})
|
||||
doc.action_compute()
|
||||
xml_bytes = doc._efs1_build_xml()
|
||||
ok, errors = doc._efs1_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'ЕФС-1 (2 ЗЛ) не прошёл XSD: %s'
|
||||
% '; '.join(errors[:5]))
|
||||
root = ET.fromstring(xml_bytes)
|
||||
zls = root.find(_q(NS_EFS1, 'ЕФС-1')).find(
|
||||
_q(NS_EFS1, 'СЗВ')).findall(_q(NS_EFS1, 'ЗЛ'))
|
||||
self.assertEqual(len(zls), 2)
|
||||
|
||||
def test_05_okz_required_validation(self):
|
||||
"""Отсутствие ОКЗ для приёма блокирует выгрузку (UserError)."""
|
||||
from odoo.exceptions import UserError
|
||||
ev = self._hire_event()
|
||||
ev.okz_code = False
|
||||
self.emp_hire.rsbu_okz_code = False
|
||||
doc = self.Efs1.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-04-01', 'date_to': '2026-04-30',
|
||||
'date_fill': '2026-04-01'})
|
||||
doc.action_compute()
|
||||
with self.assertRaises(UserError):
|
||||
doc._efs1_build_xml()
|
||||
108
rsbu_fns_reporting/tests/test_enp_notification.py
Normal file
108
rsbu_fns_reporting/tests/test_enp_notification.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestEnpNotification(TransactionCase):
|
||||
"""Уведомление об исчисленных суммах (ЕНП, UT_UVISCHSUMNAL 5.03)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_tax_office_code': '5050',
|
||||
'rsbu_oktmo': '46788000381',
|
||||
'rsbu_kbk_insurance': '18210201000011000160',
|
||||
'rsbu_kbk_ndfl': '18210102010011000110',
|
||||
'rsbu_kbk_usn': '18210501011011000110',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '5050164162', 'kpp': '505001001'})
|
||||
user = cls.env['res.users'].search([], limit=1)
|
||||
cls.company.rsbu_signatory_id = user.id
|
||||
user.partner_id.write({'name': 'Коробков Сергей Васильевич'})
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Петров Пётр Петрович',
|
||||
'company_id': cls.company.id,
|
||||
'rsbu_insured_category': 'НР',
|
||||
})
|
||||
cls.Summary = cls.env['rsbu.rsv.summary']
|
||||
cls.Notif = cls.env['rsbu.enp.notification']
|
||||
|
||||
def _summary(self, month, ops, oms, vnim):
|
||||
return self.Summary.create({
|
||||
'company_id': self.company.id,
|
||||
'employee_id': self.employee.id,
|
||||
'year': 2026, 'month': month,
|
||||
'insured_category': 'НР',
|
||||
'base': ops + oms + vnim, 'ops': ops, 'oms': oms, 'vnim': vnim,
|
||||
})
|
||||
|
||||
def test_01_period_mapping(self):
|
||||
"""Код периода и номер месяца в квартале — как в образцах 1С.
|
||||
|
||||
Ноябрь → Период 34, месяц 02; март → Период 21, месяц 03.
|
||||
"""
|
||||
nov = self.Notif.create({
|
||||
'year': 2026, 'month': 11, 'company_id': self.company.id})
|
||||
self.assertEqual(nov._quarter(), 4)
|
||||
from odoo.addons.rsbu_fns_reporting.models.rsbu_enp_notification \
|
||||
import ENP_PERIOD_CODE
|
||||
self.assertEqual(ENP_PERIOD_CODE[nov._quarter()], '34')
|
||||
self.assertEqual(nov._month_in_quarter(), '02')
|
||||
mar = self.Notif.create({
|
||||
'year': 2026, 'month': 3, 'company_id': self.company.id})
|
||||
self.assertEqual(ENP_PERIOD_CODE[mar._quarter()], '21')
|
||||
self.assertEqual(mar._month_in_quarter(), '03')
|
||||
|
||||
def test_02_autofill_insurance(self):
|
||||
"""Автозаполнение: строка взносов = ОПС+ОМС+ВНиМ за месяц."""
|
||||
self._summary(3, 22000.0, 5100.0, 2900.0)
|
||||
notif = self.Notif.create({
|
||||
'year': 2026, 'month': 3, 'company_id': self.company.id})
|
||||
notif.fill_lines()
|
||||
ins = notif.line_ids.filtered(lambda l: l.kind == 'insurance')
|
||||
self.assertTrue(ins, 'Строка взносов должна появиться')
|
||||
self.assertAlmostEqual(ins.amount, 30000.0, 2)
|
||||
self.assertEqual(ins.kbk, '18210201000011000160')
|
||||
self.assertEqual(ins.period_code, '21')
|
||||
self.assertEqual(ins.month_in_quarter, '03')
|
||||
self.assertEqual(ins.oktmo, '46788000381')
|
||||
|
||||
def test_03_xml_valid_against_xsd(self):
|
||||
"""Сгенерированное уведомление проходит XSD 5.03."""
|
||||
self._summary(3, 22000.0, 5100.0, 2900.0)
|
||||
notif = self.Notif.create({
|
||||
'year': 2026, 'month': 3, 'company_id': self.company.id})
|
||||
notif.fill_lines()
|
||||
fname = notif._enp_xml_filename()
|
||||
xml_bytes = notif._enp_build_xml(filename=fname)
|
||||
ok, errors = notif._enp_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'Уведомление не прошло XSD: %s'
|
||||
% '; '.join(errors[:8]))
|
||||
self.assertIn(b'windows-1251', xml_bytes[:60])
|
||||
self.assertTrue(fname.startswith('UT_UVISCHSUMNAL_'))
|
||||
from lxml import etree
|
||||
root = etree.fromstring(xml_bytes)
|
||||
self.assertEqual(root.get('ИдФайл'), fname)
|
||||
doc = root.find('Документ')
|
||||
self.assertEqual(doc.get('КНД'), '1110355')
|
||||
line = doc.find('УвИсчСумНалог')
|
||||
self.assertEqual(line.get('Период'), '21')
|
||||
self.assertEqual(line.get('НомерМесКварт'), '03')
|
||||
self.assertEqual(line.get('СумНалогАванс'), '30000.00')
|
||||
|
||||
def test_04_matches_live_sample_march(self):
|
||||
"""Сверка с живым образцом 1С: март 2026, взносы 78008.48.
|
||||
|
||||
Образец УФНС принят: Период 21, месяц 03, КБК взносов, ОКТМО 11.
|
||||
"""
|
||||
self._summary(3, 57000.0, 13000.0, 8008.48) # сумма 78008.48
|
||||
notif = self.Notif.create({
|
||||
'year': 2026, 'month': 3, 'company_id': self.company.id})
|
||||
notif.fill_lines()
|
||||
ins = notif.line_ids.filtered(lambda l: l.kind == 'insurance')
|
||||
self.assertAlmostEqual(ins.amount, 78008.48, 2)
|
||||
self.assertEqual(ins.kbk, '18210201000011000160')
|
||||
self.assertEqual(ins.period_code, '21')
|
||||
self.assertEqual(ins.month_in_quarter, '03')
|
||||
108
rsbu_fns_reporting/tests/test_ndfl_report.py
Normal file
108
rsbu_fns_reporting/tests/test_ndfl_report.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestNdflReport(TransactionCase):
|
||||
"""6-НДФЛ (NO_NDFL6.2 5.05): раздел 1 (6 сроков), раздел 2, XML."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_tax_office_code': '5050',
|
||||
'rsbu_oktmo': '46788000',
|
||||
'rsbu_kbk_ndfl': '18210102010011000110',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '5050164162', 'kpp': '505001001'})
|
||||
user = cls.env['res.users'].search([], limit=1)
|
||||
cls.company.rsbu_signatory_id = user.id
|
||||
user.partner_id.write({'name': 'Коробков Сергей Васильевич'})
|
||||
cls.emp1 = cls.env['hr.employee'].create({
|
||||
'name': 'Петров Пётр', 'company_id': cls.company.id})
|
||||
cls.emp2 = cls.env['hr.employee'].create({
|
||||
'name': 'Сидоров Иван', 'company_id': cls.company.id})
|
||||
cls.Summary = cls.env['rsbu.ndfl.summary']
|
||||
cls.Report = cls.env['rsbu.ndfl.report']
|
||||
|
||||
def _summary(self, emp, month, accrued, withheld, wh_date, rate=13):
|
||||
"""Создать помесячный итог НДФЛ с датой удержания."""
|
||||
from odoo import fields
|
||||
d = fields.Date.to_date(wh_date)
|
||||
term = self.Summary._deadline_term(d)
|
||||
return self.Summary.create({
|
||||
'company_id': self.company.id, 'employee_id': emp.id,
|
||||
'year': 2026, 'month': month, 'rate': rate,
|
||||
'accrued': accrued, 'deduction': 0.0, 'base': accrued,
|
||||
'computed_tax': withheld, 'withheld_tax': withheld,
|
||||
'withholding_date': d, 'withholding_month': d.month,
|
||||
'deadline_term': term,
|
||||
})
|
||||
|
||||
def test_01_deadline_term_mapping(self):
|
||||
"""Срок квартала по дате удержания: 1-22 → нечёт, 23- → чёт."""
|
||||
from odoo import fields
|
||||
# Апрель (2-й квартал, 1-й месяц квартала).
|
||||
self.assertEqual(self.Summary._deadline_term(
|
||||
fields.Date.to_date('2026-04-10')), 1) # 1-22 апр → срок 1
|
||||
self.assertEqual(self.Summary._deadline_term(
|
||||
fields.Date.to_date('2026-04-25')), 2) # 23-30 апр → срок 2
|
||||
self.assertEqual(self.Summary._deadline_term(
|
||||
fields.Date.to_date('2026-05-15')), 3) # 1-22 май → срок 3
|
||||
self.assertEqual(self.Summary._deadline_term(
|
||||
fields.Date.to_date('2026-06-28')), 6) # 23-30 июн → срок 6
|
||||
|
||||
def test_02_section1_six_terms(self):
|
||||
"""Раздел 1: удержанный НДФЛ разложен по 6 срокам квартала."""
|
||||
# Полугодие (квартал 2): удержания в апреле/мае/июне по полупериодам.
|
||||
self._summary(self.emp1, 4, 100000.0, 13000.0, '2026-04-10') # срок1
|
||||
self._summary(self.emp1, 4, 50000.0, 6500.0, '2026-04-25') # срок2
|
||||
self._summary(self.emp1, 5, 100000.0, 13000.0, '2026-05-12') # срок3
|
||||
self._summary(self.emp1, 6, 100000.0, 13000.0, '2026-06-20') # срок5
|
||||
rep = self.Report.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '2'})
|
||||
rep.compute_from_summary()
|
||||
self.assertAlmostEqual(rep.term1, 13000.0, 2)
|
||||
self.assertAlmostEqual(rep.term2, 6500.0, 2)
|
||||
self.assertAlmostEqual(rep.term3, 13000.0, 2)
|
||||
self.assertAlmostEqual(rep.term4, 0.0, 2)
|
||||
self.assertAlmostEqual(rep.term5, 13000.0, 2)
|
||||
self.assertAlmostEqual(rep.term6, 0.0, 2)
|
||||
self.assertAlmostEqual(rep.withheld_total, 45500.0, 2)
|
||||
|
||||
def test_03_section2_by_rate(self):
|
||||
"""Раздел 2: строка по ставке — число лиц, доход, удержано."""
|
||||
self._summary(self.emp1, 4, 100000.0, 13000.0, '2026-04-10')
|
||||
self._summary(self.emp2, 4, 80000.0, 10400.0, '2026-04-10')
|
||||
rep = self.Report.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '2'})
|
||||
rep.compute_from_summary()
|
||||
line = rep.line_ids.filtered(lambda l: l.rate == 13)
|
||||
self.assertTrue(line)
|
||||
self.assertEqual(line.persons_count, 2)
|
||||
self.assertAlmostEqual(line.accrued, 180000.0, 2)
|
||||
self.assertAlmostEqual(line.withheld_tax, 23400.0, 2)
|
||||
|
||||
def test_04_xml_valid_against_xsd(self):
|
||||
"""Сгенерированный 6-НДФЛ проходит XSD 5.05."""
|
||||
self._summary(self.emp1, 4, 100000.0, 13000.0, '2026-04-10')
|
||||
self._summary(self.emp1, 5, 100000.0, 13000.0, '2026-05-12')
|
||||
self._summary(self.emp1, 6, 100000.0, 13000.0, '2026-06-20')
|
||||
rep = self.Report.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '2'})
|
||||
rep.compute_from_summary()
|
||||
fname = rep._ndfl_xml_filename()
|
||||
xml_bytes = rep._ndfl_build_xml(filename=fname)
|
||||
ok, errors = rep._ndfl_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, '6-НДФЛ не прошёл XSD: %s' % '; '.join(errors[:8]))
|
||||
self.assertIn(b'windows-1251', xml_bytes[:60])
|
||||
self.assertTrue(fname.startswith('NO_NDFL6.2_'))
|
||||
from lxml import etree
|
||||
root = etree.fromstring(xml_bytes)
|
||||
self.assertEqual(root.get('ИдФайл'), fname)
|
||||
doc = root.find('Документ')
|
||||
self.assertEqual(doc.get('КНД'), '1151100')
|
||||
self.assertEqual(doc.get('Период'), '31')
|
||||
obyaz = doc.find('НДФЛ6.2/ОбязНА')
|
||||
self.assertEqual(obyaz.get('КБК'), '18210102010011000110')
|
||||
116
rsbu_fns_reporting/tests/test_nds_declaration.py
Normal file
116
rsbu_fns_reporting/tests/test_nds_declaration.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestNdsDeclaration(TransactionCase):
|
||||
"""Декларация НДС (комплект 3 файла): расчёт из книг, XML по XSD 5.12."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_vat_mode': 'vat_5',
|
||||
'rsbu_tax_office_code': '7704',
|
||||
'rsbu_oktmo': '45301000',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '7704567896', 'kpp': '770401001'})
|
||||
cls.user = cls.env['res.users'].search([], limit=1)
|
||||
cls.company.rsbu_signatory_id = cls.user.id
|
||||
cls.Decl = cls.env['rsbu.nds.declaration']
|
||||
cls.BookLine = cls.env['rsbu.vat.book.line']
|
||||
cls.buyer = cls.env['res.partner'].create({
|
||||
'name': 'ООО Покупатель', 'inn': '7730179829',
|
||||
'kpp': '773001001'})
|
||||
|
||||
def _sales_line(self, base, vat):
|
||||
return self.BookLine.create({
|
||||
'company_id': self.company.id, 'book': 'sales',
|
||||
'date': '2026-04-15', 'base_amount': base, 'vat_amount': vat,
|
||||
'partner_id': self.buyer.id})
|
||||
|
||||
def _purchase_line(self, vat):
|
||||
return self.BookLine.create({
|
||||
'company_id': self.company.id, 'book': 'purchases',
|
||||
'date': '2026-05-10', 'vat_amount': vat,
|
||||
'partner_id': self.company.partner_id.id})
|
||||
|
||||
def _declaration(self):
|
||||
return self.Decl.create({
|
||||
'company_id': self.company.id,
|
||||
'date_from': '2026-04-01', 'date_to': '2026-06-30'})
|
||||
|
||||
def _compute_no_recompute(self, dec):
|
||||
"""Расчёт без пересбора книг (иначе тестовые строки затрутся).
|
||||
|
||||
recompute_period пересобирает книги из проводок; в тесте их нет,
|
||||
поэтому вызываем внутренний расчёт по существующим строкам напрямую.
|
||||
"""
|
||||
# Разложение и суммы считаем по уже созданным строкам книги.
|
||||
real_vat, adv_vat = dec._split_sales_vat()
|
||||
fraction = 5.0 / 105.0
|
||||
inv = 1.0 / fraction - 1.0
|
||||
dec.vat_tax_realization = real_vat
|
||||
dec.vat_base_realization = round(real_vat * inv, 2)
|
||||
dec.vat_tax_advance = adv_vat
|
||||
dec.vat_base_advance = round(adv_vat / fraction, 2) if adv_vat else 0.0
|
||||
dec.vat_charged_total = round(real_vat + adv_vat, 2)
|
||||
ded = sum(self.BookLine.search([
|
||||
('company_id', '=', self.company.id), ('book', '=', 'purchases'),
|
||||
('date', '>=', dec.date_from), ('date', '<=', dec.date_to),
|
||||
]).mapped('vat_amount'))
|
||||
dec.vat_deduction = round(ded, 2)
|
||||
dec.vat_payable = round(dec.vat_charged_total - ded, 2)
|
||||
dec.oktmo = self.company.rsbu_oktmo
|
||||
dec.kbk = self.company.rsbu_kbk_nds
|
||||
dec.state = 'computed'
|
||||
|
||||
def test_01_totals(self):
|
||||
"""Начислено = книга продаж, вычет = книга покупок, к уплате = разница."""
|
||||
self._sales_line(100000.0, 5000.0) # реализация 5%
|
||||
self._purchase_line(2000.0) # вычет
|
||||
dec = self._declaration()
|
||||
self._compute_no_recompute(dec)
|
||||
self.assertAlmostEqual(dec.vat_tax_realization, 5000.0, 2)
|
||||
self.assertAlmostEqual(dec.vat_base_realization, 100000.0, 2)
|
||||
self.assertAlmostEqual(dec.vat_charged_total, 5000.0, 2)
|
||||
self.assertAlmostEqual(dec.vat_deduction, 2000.0, 2)
|
||||
self.assertAlmostEqual(dec.vat_payable, 3000.0, 2)
|
||||
|
||||
def test_02_three_files_valid_against_xsd(self):
|
||||
"""Комплект из 3 файлов валиден против XSD 5.12."""
|
||||
self._sales_line(100000.0, 5000.0)
|
||||
self._purchase_line(2000.0)
|
||||
dec = self._declaration()
|
||||
self._compute_no_recompute(dec)
|
||||
files = dec._nds_build_all()
|
||||
self.assertEqual(len(files), 3)
|
||||
for (fname, xml), kind in zip(files, ['root', 'purchases', 'sales']):
|
||||
ok, errors = dec._nds_validate_xml(xml, kind)
|
||||
self.assertTrue(ok, '%s не прошёл XSD: %s'
|
||||
% (kind, '; '.join(errors[:4])))
|
||||
self.assertIn(b'windows-1251', xml[:60])
|
||||
|
||||
def test_03_root_references_sections(self):
|
||||
"""Корневая декларация ссылается на файлы разделов 8 и 9."""
|
||||
self._sales_line(100000.0, 5000.0)
|
||||
self._purchase_line(2000.0)
|
||||
dec = self._declaration()
|
||||
self._compute_no_recompute(dec)
|
||||
files = dec._nds_build_all()
|
||||
name_root, name_pokup, name_prod = (f[0] for f in files)
|
||||
root_xml = files[0][1].decode('cp1251')
|
||||
self.assertIn(name_pokup, root_xml) # КнигаПокуп/НаимКнПок
|
||||
self.assertIn(name_prod, root_xml) # КнигаПрод/НаимКнПрод
|
||||
self.assertTrue(name_pokup.startswith('NO_NDS.8_'))
|
||||
self.assertTrue(name_prod.startswith('NO_NDS.9_'))
|
||||
|
||||
def test_04_advance_split(self):
|
||||
"""Начисленный НДС делится на реализацию и авансы (по 76.АВ)."""
|
||||
# Реализация (без 76.АВ) и аванс (эмулируем через отдельную строку).
|
||||
self._sales_line(100000.0, 5000.0)
|
||||
dec = self._declaration()
|
||||
real, adv = dec._split_sales_vat()
|
||||
# Без авансовых проводок весь НДС — реализация.
|
||||
self.assertAlmostEqual(real, 5000.0, 2)
|
||||
self.assertAlmostEqual(adv, 0.0, 2)
|
||||
78
rsbu_fns_reporting/tests/test_pers_info.py
Normal file
78
rsbu_fns_reporting/tests/test_pers_info.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPersInfo(TransactionCase):
|
||||
"""Персонифицированные сведения (NO_PERSSVFL 5.01): автозаполнение, XML."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_tax_office_code': '5050',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '5050164162', 'kpp': '505001001'})
|
||||
user = cls.env['res.users'].search([], limit=1)
|
||||
cls.company.rsbu_signatory_id = user.id
|
||||
user.partner_id.write({'name': 'Коробков Сергей Васильевич'})
|
||||
cls.emp1 = cls.env['hr.employee'].create({
|
||||
'name': 'Горский Алексей Алексеевич', 'company_id': cls.company.id,
|
||||
'rsbu_snils': '195-652-650 11', 'rsbu_inn': '780604730641',
|
||||
'rsbu_insured_category': 'НР'})
|
||||
cls.emp2 = cls.env['hr.employee'].create({
|
||||
'name': 'Ефимова Анна Александровна', 'company_id': cls.company.id,
|
||||
'rsbu_snils': '162-601-022 16', 'rsbu_inn': '742210150850',
|
||||
'rsbu_insured_category': 'НР'})
|
||||
cls.Summary = cls.env['rsbu.rsv.summary']
|
||||
cls.Psv = cls.env['rsbu.pers.info']
|
||||
|
||||
def _summary(self, emp, month, payments):
|
||||
return self.Summary.create({
|
||||
'company_id': self.company.id, 'employee_id': emp.id,
|
||||
'year': 2026, 'month': month, 'insured_category': 'НР',
|
||||
'payments': payments, 'base': payments})
|
||||
|
||||
def test_01_autofill_from_summary(self):
|
||||
"""Строки заполняются по сотрудникам с выплатами за месяц."""
|
||||
self._summary(self.emp1, 9, 80000.0)
|
||||
self._summary(self.emp2, 9, 65000.0)
|
||||
psv = self.Psv.create({
|
||||
'year': 2026, 'month': 9, 'company_id': self.company.id})
|
||||
psv.fill_lines()
|
||||
self.assertEqual(len(psv.line_ids), 2)
|
||||
l1 = psv.line_ids.filtered(lambda l: l.employee_id == self.emp1)
|
||||
self.assertAlmostEqual(l1.amount, 80000.0, 2)
|
||||
self.assertEqual(l1.snils, '195-652-650 11')
|
||||
|
||||
def test_02_snils_normalized(self):
|
||||
"""СНИЛС нормализуется к формату NNN-NNN-NNN-NN (дефисы)."""
|
||||
psv = self.Psv.create({
|
||||
'year': 2026, 'month': 9, 'company_id': self.company.id})
|
||||
self.assertEqual(psv._snils('195-652-650 11'), '195-652-650-11')
|
||||
self.assertEqual(psv._snils('19565265011'), '195-652-650-11')
|
||||
|
||||
def test_03_xml_valid_against_xsd(self):
|
||||
"""Сгенерированный ПСВ проходит XSD 5.01."""
|
||||
self._summary(self.emp1, 9, 80000.0)
|
||||
self._summary(self.emp2, 9, 65000.0)
|
||||
psv = self.Psv.create({
|
||||
'year': 2026, 'month': 9, 'company_id': self.company.id})
|
||||
psv.fill_lines()
|
||||
fname = psv._psv_xml_filename()
|
||||
xml_bytes = psv._psv_build_xml(filename=fname)
|
||||
ok, errors = psv._psv_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'ПСВ не прошёл XSD: %s' % '; '.join(errors[:8]))
|
||||
self.assertIn(b'windows-1251', xml_bytes[:60])
|
||||
self.assertTrue(fname.startswith('NO_PERSSVFL_'))
|
||||
from lxml import etree
|
||||
root = etree.fromstring(xml_bytes)
|
||||
self.assertEqual(root.get('ИдФайл'), fname)
|
||||
doc = root.find('Документ')
|
||||
self.assertEqual(doc.get('КНД'), '1151162')
|
||||
self.assertEqual(doc.get('Период'), '09')
|
||||
pers = doc.findall('ПерсСвФЛ')
|
||||
self.assertEqual(len(pers), 2)
|
||||
# СНИЛС в XML — с дефисами (как требует XSD).
|
||||
self.assertEqual(pers[0].get('СНИЛС'), '195-652-650-11')
|
||||
124
rsbu_fns_reporting/tests/test_rsv_declaration.py
Normal file
124
rsbu_fns_reporting/tests/test_rsv_declaration.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRsvDeclaration(TransactionCase):
|
||||
"""РСВ (NO_RASCHSV 5.08): расчёт разделов из итогов и выгрузка XML."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_tax_office_code': '7704',
|
||||
'rsbu_oktmo': '45301000',
|
||||
'rsbu_msp_enabled': True,
|
||||
'rsbu_payer_tariff_code': '01',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '7704567896', 'kpp': '770401001'})
|
||||
# Подписант.
|
||||
user = cls.env['res.users'].search([], limit=1)
|
||||
cls.company.rsbu_signatory_id = user.id
|
||||
user.partner_id.write({'name': 'Иванов Иван Иванович'})
|
||||
# Сотрудник с полными персональными данными (раздел 3).
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'Петров Пётр Петрович',
|
||||
'company_id': cls.company.id,
|
||||
'birthday': '1985-03-15',
|
||||
'rsbu_snils': '112-233-445 95',
|
||||
'rsbu_inn': '770412345678',
|
||||
'rsbu_insured_category': 'НР',
|
||||
'rsbu_sex': '1',
|
||||
'rsbu_citizenship_code': '643',
|
||||
'rsbu_id_doc_code': '21',
|
||||
'rsbu_id_doc_number': '45 04 123456',
|
||||
})
|
||||
cls.Summary = cls.env['rsbu.rsv.summary']
|
||||
cls.Decl = cls.env['rsbu.rsv.declaration']
|
||||
|
||||
def _summary(self, month, base, ops, oms, vnim, payments=None,
|
||||
within=None, over=0.0):
|
||||
"""Создать помесячный итог взносов сотрудника."""
|
||||
return self.Summary.create({
|
||||
'company_id': self.company.id,
|
||||
'employee_id': self.employee.id,
|
||||
'year': 2026, 'month': month,
|
||||
'insured_category': 'НР',
|
||||
'payments': payments if payments is not None else base,
|
||||
'non_taxable': 0.0,
|
||||
'base': base,
|
||||
'base_within_limit': within if within is not None else base,
|
||||
'base_over_limit': over,
|
||||
'ops': ops, 'ops_within': ops, 'ops_over': 0.0,
|
||||
'oms': oms, 'vnim': vnim,
|
||||
})
|
||||
|
||||
def test_01_compute_section1_ytd(self):
|
||||
"""Раздел 1 считается нарастающим итогом с начала года."""
|
||||
# I квартал: по 100 000 базы в месяц, ОПС 22000, ОМС 5100, ВНиМ 2900.
|
||||
for m in (1, 2, 3):
|
||||
self._summary(m, 100000.0, 22000.0, 5100.0, 2900.0)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '1'})
|
||||
dec.compute_from_summary()
|
||||
self.assertAlmostEqual(dec.base_total, 300000.0, 2)
|
||||
self.assertAlmostEqual(dec.charged_total, 66000.0, 2) # ОПС 3×22000
|
||||
self.assertAlmostEqual(dec.oms_total, 15300.0, 2)
|
||||
self.assertAlmostEqual(dec.vnim_total, 8700.0, 2)
|
||||
self.assertEqual(dec.insured_count, 1)
|
||||
# Помесячно единый тариф = ОПС+ОМС+ВНиМ = 30000.
|
||||
self.assertAlmostEqual(dec.contrib_month1, 30000.0, 2)
|
||||
self.assertAlmostEqual(dec.contrib_period, 90000.0, 2)
|
||||
|
||||
def test_02_pers_lines_built(self):
|
||||
"""Раздел 3: строки по сотрудникам за 3 месяца квартала."""
|
||||
for m in (1, 2, 3):
|
||||
self._summary(m, 100000.0, 22000.0, 5100.0, 2900.0)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '1'})
|
||||
dec.compute_from_summary()
|
||||
self.assertEqual(len(dec.pers_line_ids), 1)
|
||||
line = dec.pers_line_ids
|
||||
self.assertEqual(line.employee_id, self.employee)
|
||||
self.assertAlmostEqual(line.base_m1, 100000.0, 2)
|
||||
self.assertAlmostEqual(line.ops_m3, 22000.0, 2)
|
||||
|
||||
def test_03_regression_split_in_section1(self):
|
||||
"""База сверх предела ОПС отражается в БазПревышОПС раздела 1."""
|
||||
# Месяц 3: часть базы сверх лимита.
|
||||
self._summary(1, 100000.0, 22000.0, 5100.0, 2900.0)
|
||||
self._summary(2, 100000.0, 22000.0, 5100.0, 2900.0)
|
||||
self._summary(3, 100000.0, 15000.0, 5100.0, 2900.0,
|
||||
within=50000.0, over=50000.0)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '1'})
|
||||
dec.compute_from_summary()
|
||||
self.assertAlmostEqual(dec.base_within_total, 250000.0, 2)
|
||||
self.assertAlmostEqual(dec.base_over_total, 50000.0, 2)
|
||||
|
||||
def test_04_xml_valid_against_xsd(self):
|
||||
"""Сгенерированный РСВ проходит проверку по XSD 5.08."""
|
||||
for m in (1, 2, 3):
|
||||
self._summary(m, 100000.0, 22000.0, 5100.0, 2900.0)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '1',
|
||||
'msp_income_total': 5000000.0, 'msp_income_main': 5000000.0})
|
||||
dec.compute_from_summary()
|
||||
fname = dec._rsv_xml_filename()
|
||||
xml_bytes = dec._rsv_build_xml(filename=fname)
|
||||
ok, errors = dec._rsv_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'РСВ не прошёл XSD: %s' % '; '.join(errors[:8]))
|
||||
self.assertIn(b'windows-1251', xml_bytes[:60])
|
||||
self.assertTrue(fname.startswith('NO_RASCHSV_'))
|
||||
from lxml import etree
|
||||
root = etree.fromstring(xml_bytes)
|
||||
self.assertEqual(root.get('ИдФайл'), fname)
|
||||
self.assertEqual(root.find('Документ').get('КНД'), '1151111')
|
||||
|
||||
def test_05_snils_normalized_with_dashes(self):
|
||||
"""СНИЛС нормализуется к формату NNN-NNN-NNN-NN (дефисы)."""
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id, 'quarter': '1'})
|
||||
self.assertEqual(dec._snils('112-233-445 95'), '112-233-445-95')
|
||||
self.assertEqual(dec._snils('11223344595'), '112-233-445-95')
|
||||
593
rsbu_fns_reporting/tests/test_upd_xml.py
Normal file
593
rsbu_fns_reporting/tests/test_upd_xml.py
Normal file
@@ -0,0 +1,593 @@
|
||||
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
|
||||
128
rsbu_fns_reporting/tests/test_usn_declaration.py
Normal file
128
rsbu_fns_reporting/tests/test_usn_declaration.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsnDeclaration(TransactionCase):
|
||||
"""Декларация УСН «Доходы»: расчёт разделов 1.1/2.1.1 и XML."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.company.write({
|
||||
'rsbu_usn_rate': 6.0,
|
||||
'rsbu_tax_office_code': '7704',
|
||||
'rsbu_oktmo': '45301000',
|
||||
})
|
||||
cls.company.partner_id.write({'inn': '7704567896', 'kpp': '770401001'})
|
||||
cls.Decl = cls.env['rsbu.usn.declaration']
|
||||
cls.Summary = cls.env['rsbu.kudir.summary']
|
||||
|
||||
def _summary(self, quarter, income, insurance=0.0):
|
||||
"""Создать запись итогов КУДиР за квартал года."""
|
||||
return self.Summary.create({
|
||||
'company_id': self.company.id,
|
||||
'period_key': '2026-Q%s' % quarter,
|
||||
'income': income,
|
||||
'expense': 0.0,
|
||||
'insurance': insurance,
|
||||
})
|
||||
|
||||
def test_01_progression_of_advances(self):
|
||||
"""Авансы по срокам считаются нарастающим итогом (без взносов).
|
||||
|
||||
Квартальные доходы (summary хранит за квартал, ytd суммирует):
|
||||
Q1 1 000 000, Q2 500 000, Q3 500 000, Q4 1 000 000.
|
||||
Нарастающим: Q1 1М, H1 1.5М, 9м 2М, год 3М. Ставка 6%.
|
||||
Исчислено: 60000/90000/120000/180000. Взносов нет.
|
||||
Авансы: 020=60000, 040=30000, 070=30000, 100=60000.
|
||||
"""
|
||||
self._summary(1, 1_000_000)
|
||||
self._summary(2, 500_000)
|
||||
self._summary(3, 500_000)
|
||||
self._summary(4, 1_000_000)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id,
|
||||
'taxpayer_sign': '1', 'rate': 6.0})
|
||||
dec.compute_from_summary()
|
||||
self.assertAlmostEqual(dec.tax_q1, 60000.0, 2)
|
||||
self.assertAlmostEqual(dec.tax_year, 180000.0, 2)
|
||||
self.assertAlmostEqual(dec.adv_q1, 60000.0, 2) # стр.020
|
||||
self.assertAlmostEqual(dec.adv_h1, 30000.0, 2) # стр.040
|
||||
self.assertAlmostEqual(dec.adv_9m, 30000.0, 2) # стр.070
|
||||
self.assertAlmostEqual(dec.tax_due_year, 60000.0, 2) # стр.100
|
||||
|
||||
def test_02_reduction_limited_50pct(self):
|
||||
"""Вычет взносов для признака «1» ограничен 50% исчисленного."""
|
||||
self._summary(1, 1_000_000, insurance=40000.0)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id,
|
||||
'taxpayer_sign': '1', 'rate': 6.0})
|
||||
dec.compute_from_summary()
|
||||
# Исчислено 60000; взносы 40000, но лимит 50% = 30000.
|
||||
self.assertAlmostEqual(dec.tax_q1, 60000.0, 2)
|
||||
self.assertAlmostEqual(dec.ded_q1, 30000.0, 2)
|
||||
self.assertAlmostEqual(dec.adv_q1, 30000.0, 2) # 60000 − 30000
|
||||
|
||||
def test_03_advance_reduce_when_income_drops(self):
|
||||
"""Аванс к уменьшению (050), если исчисленное за полугодие < аванса Q1.
|
||||
|
||||
Q1 доход 1М (аванс 60000). Полугодие доход тот же 1М (нет прироста)
|
||||
→ (131−141)−020 = 60000 − 60000 = 0; при возврате дохода строка 050.
|
||||
Здесь проверяем неотрицательность и отсутствие двойного счёта.
|
||||
"""
|
||||
self._summary(1, 1_000_000)
|
||||
self._summary(2, 0.0) # прироста во II квартале нет → H1 = Q1
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id,
|
||||
'taxpayer_sign': '1', 'rate': 6.0})
|
||||
dec.compute_from_summary()
|
||||
self.assertAlmostEqual(dec.adv_q1, 60000.0, 2)
|
||||
self.assertAlmostEqual(dec.adv_h1, 0.0, 2)
|
||||
self.assertAlmostEqual(dec.adv_h1_reduce, 0.0, 2)
|
||||
|
||||
def test_04_xml_valid_against_xsd(self):
|
||||
"""Сгенерированный XML проходит проверку по XSD-схеме ФНС."""
|
||||
self._summary(1, 1_000_000)
|
||||
self._summary(2, 500_000)
|
||||
self._summary(3, 500_000)
|
||||
self._summary(4, 1_000_000)
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id,
|
||||
'taxpayer_sign': '1', 'rate': 6.0})
|
||||
dec.compute_from_summary()
|
||||
fname = dec._usn_xml_filename()
|
||||
xml_bytes = dec._usn_build_xml(filename=fname)
|
||||
ok, errors = dec._usn_validate_xml(xml_bytes)
|
||||
self.assertTrue(ok, 'XML не прошёл XSD: %s' % '; '.join(errors[:5]))
|
||||
# Кодировка windows-1251 в декларации XML.
|
||||
self.assertIn(b'windows-1251', xml_bytes[:60])
|
||||
# Префикс имени файла обмена — NO_USN (Порядок п.3).
|
||||
self.assertTrue(fname.startswith('NO_USN_'))
|
||||
# ИдФайл в XML совпадает с именем файла обмена.
|
||||
from lxml import etree
|
||||
root = etree.fromstring(xml_bytes)
|
||||
self.assertEqual(root.get('ИдФайл'), fname)
|
||||
|
||||
def test_05_export_always_recomputes(self):
|
||||
"""Выгрузка XML всегда пересчитывает данные (свежесть гарантирована).
|
||||
|
||||
Даже если декларация уже в состоянии «Рассчитана», action_export_xml
|
||||
обязан вызвать action_compute перед формированием файла — иначе
|
||||
выгрузятся устаревшие суммы после изменения КУДиР.
|
||||
"""
|
||||
dec = self.Decl.create({
|
||||
'year': 2026, 'company_id': self.company.id,
|
||||
'taxpayer_sign': '1', 'rate': 6.0})
|
||||
dec.action_compute()
|
||||
self.assertEqual(dec.state, 'computed')
|
||||
calls = []
|
||||
orig = type(dec).action_compute
|
||||
|
||||
def _spy(self2):
|
||||
calls.append(1)
|
||||
return orig(self2)
|
||||
|
||||
self.patch(type(dec), 'action_compute', _spy)
|
||||
dec.action_export_xml()
|
||||
self.assertTrue(calls, 'action_export_xml должен вызвать пересчёт')
|
||||
Reference in New Issue
Block a user