from odoo.tests import TransactionCase, tagged @tagged('post_install', '-at_install') class TestRsbuPayroll(TransactionCase): """Тесты кадрово-расчётного блока РСБУ (Этап 7).""" @classmethod def setUpClass(cls): super().setUpClass() cls.company = cls.env.company cls.company.write({ 'rsbu_ndfl_rate': 13.0, 'rsbu_insurance_rate': 30.0, 'rsbu_injury_rate': 0.2, }) cls.struct = cls.env.ref('rsbu_payroll.rsbu_structure_ru') def _acc(self, code): return self.env['rsbu.account'].search([ ('code', '=', code), ('company_id', '=', self.company.id)], limit=1) def _employee(self, name, wage): return self.env['hr.employee'].create({ 'name': name, 'company_id': self.company.id, 'date_version': '2026-04-01', 'contract_date_start': '2026-04-01', 'struct_id': self.struct.id, 'wage': wage, }) def _payslip(self, employee, run=None): vals = { 'name': 'ЗП %s' % employee.name, 'employee_id': employee.id, 'struct_id': self.struct.id, 'date_from': '2026-04-01', 'date_to': '2026-04-30', } if run: vals['payslip_run_id'] = run.id slip = self.env['hr.payslip'].create(vals) slip.compute_sheet() return slip # ------------------------------------------------------------------ def test_01_compute_amounts(self): """Расчёт: оклад, НДФЛ 13%, взносы 30%, травматизм 0.2%, NET.""" slip = self._payslip(self._employee('Расчёт', 60000.0)) by_code = {l.code: l.total for l in slip.line_ids} self.assertAlmostEqual(by_code['GROSS'], 60000.0, 2) self.assertAlmostEqual(by_code['NDFL'], -7800.0, 2) self.assertAlmostEqual(by_code['INSURANCE'], 18000.0, 2) self.assertAlmostEqual(by_code['INJURY'], 120.0, 2) self.assertAlmostEqual(by_code['NET'], 52200.0, 2) def test_02_posting_creates_balanced_move(self): """Проведение payslip → сбалансированная РСБУ-проводка с employee.""" emp = self._employee('Проводка', 60000.0) slip = self._payslip(emp) slip.action_payslip_done() move = slip.rsbu_move_id self.assertTrue(move, 'РСБУ-проводка не создана') self.assertEqual(move.state, 'posted') self.assertAlmostEqual(move.total_debit, move.total_credit, 2) # Все строки несут субконто сотрудника. self.assertTrue(all(l.employee_id == emp for l in move.line_ids)) # Направление НДФЛ: Дт 70 / Кт 68.НДФЛ. ndfl = move.line_ids.filtered(lambda l: l.account_id.code == '68.НДФЛ') self.assertTrue(ndfl and ndfl.credit > 0 and not ndfl.debit) acc70_debit = move.line_ids.filtered( lambda l: l.account_id.code == '70' and l.debit > 0) self.assertAlmostEqual(sum(acc70_debit.mapped('debit')), 7800.0, 2) def test_03_idempotent_posting(self): """Повторное проведение не создаёт дубль проводки.""" slip = self._payslip(self._employee('Идемпотент', 50000.0)) slip.action_payslip_done() move_id = slip.rsbu_move_id.id slip._rsbu_generate() self.assertEqual(slip.rsbu_move_id.id, move_id) def test_04_draft_removes_move(self): """Возврат расчётного листка в черновик удаляет РСБУ-проводку.""" slip = self._payslip(self._employee('Отмена', 50000.0)) slip.action_payslip_done() self.assertTrue(slip.rsbu_move_id) slip.action_payslip_draft() self.assertFalse(slip.rsbu_move_id) def test_05_payroll_register_payout(self): """Реестр: кнопка выплаты → Дт70-Кт51 по сотрудникам + комиссия.""" bank = self.env['res.partner'].create({'name': 'Т-Банк', 'is_company': True}) run = self.env['hr.payslip.run'].create({ 'name': 'Реестр тест', 'company_id': self.company.id, 'payment_bank_partner_id': bank.id, 'payment_date': '2026-05-05', 'bank_fee_amount': 300.0, }) e1 = self._employee('Иванов', 60000.0) e2 = self._employee('Сидоров', 40000.0) s1 = self._payslip(e1, run=run) s2 = self._payslip(e2, run=run) # OCA payroll: action_payslip_done рассчитан на singleton — проводим # каждый расчётный листок отдельно (как в UI). s1.action_payslip_done() s2.action_payslip_done() self.assertAlmostEqual(run.total_net, 87000.0, 2) run.action_rsbu_pay() move = run.rsbu_payout_move_id self.assertTrue(move) self.assertEqual(run.payout_state, 'paid') self.assertAlmostEqual(move.total_debit, move.total_credit, 2) # Кт 51 = Σ NET + комиссия. acc51_credit = move.line_ids.filtered( lambda l: l.account_id.code == '51').credit self.assertAlmostEqual(acc51_credit, 87300.0, 2) # Дт 70 по каждому сотруднику. emp70 = move.line_ids.filtered( lambda l: l.account_id.code == '70' and l.debit > 0) self.assertEqual(len(emp70), 2) self.assertEqual(set(emp70.mapped('employee_id')), {e1, e2}) # Идемпотентность. mid = run.rsbu_payout_move_id.id run.action_rsbu_pay() self.assertEqual(run.rsbu_payout_move_id.id, mid) # Отмена. run.action_rsbu_pay_cancel() self.assertFalse(run.rsbu_payout_move_id) self.assertEqual(run.payout_state, 'to_pay') # ------------------------------------------------------------------ # НДФЛ: прогрессия, вычеты, нерезидент # ------------------------------------------------------------------ def test_07_ndfl_progressive_scale(self): """Прогрессивная шкала: пример из НК (база 6М → 882 000).""" P = self.env['hr.payslip'] self.assertAlmostEqual(P._ndfl_progressive_tax(2_400_000), 312000.0, 2) self.assertAlmostEqual(P._ndfl_progressive_tax(5_000_000), 702000.0, 2) self.assertAlmostEqual(P._ndfl_progressive_tax(6_000_000), 882000.0, 2) def test_08_ndfl_ytd_threshold_crossing(self): """НДФЛ нарастающим итогом: переход порога 2.4М между месяцами. Зарплата 2 000 000/мес. Месяц 1: база 2М < 2.4М → 13% = 260 000. Месяц 2: YTD 4М, налог YTD = 2.4М×13% + 1.6М×15% = 312000+240000 = 552 000; за месяц = 552 000 − 260 000 = 292 000. """ emp = self._employee('Крупный', 2_000_000.0) s1 = self._payslip(emp) self.assertAlmostEqual( {l.code: l.total for l in s1.line_ids}['NDFL'], -260000.0, 2) s1.action_payslip_done() # Месяц 2 (май). s2 = self.env['hr.payslip'].create({ 'name': 'ЗП2', 'employee_id': emp.id, 'struct_id': self.struct.id, 'date_from': '2026-05-01', 'date_to': '2026-05-31'}) s2.compute_sheet() self.assertAlmostEqual( {l.code: l.total for l in s2.line_ids}['NDFL'], -292000.0, 2) def test_09_ndfl_child_deduction(self): """Детский вычет уменьшает базу НДФЛ (1 ребёнок = 1400 ₽/мес).""" emp = self._employee('Родитель', 60000.0) self.env['rsbu.employee.child'].create({ 'employee_id': emp.id, 'name': 'Ребёнок', 'order': 1, 'birthdate': '2015-06-01'}) slip = self._payslip(emp) # База 60000 − 1400 = 58600 × 13% = 7618. self.assertAlmostEqual( {l.code: l.total for l in slip.line_ids}['NDFL'], -7618.0, 2) def test_10_ndfl_nonresident_flat_30(self): """Обычный нерезидент: 30% на доход без вычетов и прогрессии.""" emp = self._employee('Нерезидент', 100000.0) emp.rsbu_taxpayer_status = '2' slip = self._payslip(emp) self.assertAlmostEqual( {l.code: l.total for l in slip.line_ids}['NDFL'], -30000.0, 2) def test_11_insurance_breakdown(self): """Разбивка единого тарифа на ОПС/ОМС/ВНиМ (22/5.1/2.9 из 30%).""" slip = self._payslip(self._employee('Взносы', 100000.0)) b = slip.rsbu_insurance_breakdown(base=100000.0) self.assertAlmostEqual(b['ops'], 22000.0, 2) self.assertAlmostEqual(b['oms'], 5100.0, 2) self.assertAlmostEqual(b['vnim'], 2900.0, 2) self.assertAlmostEqual(b['total'], 30000.0, 2) def test_12_insurance_msp_reduced(self): """МСП: выплата сверх 1.5 МРОТ по льготному тарифу 15%. МРОТ 20000, порог 1.5×20000=30000. База 100000: 30000 по единому 30% = 9000; 70000 по МСП 15% = 10500; итого 19500. """ self.company.write({ 'rsbu_msp_enabled': True, 'rsbu_msp_rate': 15.0, 'rsbu_min_wage': 20000.0, }) slip = self._payslip(self._employee('МСП', 100000.0)) b = slip.rsbu_insurance_breakdown(base=100000.0) self.assertAlmostEqual(b['base_regular'], 30000.0, 2) self.assertAlmostEqual(b['base_msp'], 70000.0, 2) self.assertAlmostEqual(b['total'], 19500.0, 2) def test_13_insurance_regression_base_limit(self): """Регрессия: база сверх годовой предельной базы ОПС — отдельно. Годовая предельная база 150 000. Первый листок база 100 000 — весь в пределах (YTD до него = 0). Второй листок база 100 000 — YTD уже 100 000, до лимита осталось 50 000: 50 000 в пределах, 50 000 сверх. """ self.company.write({'rsbu_insurance_base_limit': 150000.0}) emp = self._employee('Регресс', 100000.0) s1 = self._payslip(emp) s1.action_payslip_done() b1 = s1.rsbu_insurance_breakdown(base=100000.0) self.assertAlmostEqual(b1['base_within_limit'], 100000.0, 2) self.assertAlmostEqual(b1['base_over_limit'], 0.0, 2) # Второй месяц — YTD база 100 000, лимит 150 000. s2 = self.env['hr.payslip'].create({ 'name': 'ЗП2', 'employee_id': emp.id, 'struct_id': self.struct.id, 'date_from': '2026-05-01', 'date_to': '2026-05-31'}) s2.compute_sheet() b2 = s2.rsbu_insurance_breakdown(base=100000.0) self.assertAlmostEqual(b2['base_within_limit'], 50000.0, 2) self.assertAlmostEqual(b2['base_over_limit'], 50000.0, 2) def test_06_kudir_recognizes_insurance_payment(self): """Уплата взносов (Дт 69 — Кт 51) попадает в раздел IV КУДиР.""" acc_69et = self._acc('69.ЕТ') acc_51 = self._acc('51') move = self.env['rsbu.move'].create({ 'date': '2026-05-10', 'company_id': self.company.id, 'usn_recognition': 'expense', 'line_ids': [ (0, 0, {'account_id': acc_69et.id, 'debit': 18000.0}), (0, 0, {'account_id': acc_51.id, 'credit': 18000.0}), ], }) move.action_post() self.env['rsbu.kudir.line'].recompute_period( '2026-04-01', '2026-06-30', self.company) lines = self.env['rsbu.kudir.line'].search([ ('company_id', '=', self.company.id), ('section', '=', 'section_4'), ('move_id', '=', move.id)]) self.assertTrue(lines, 'Уплата взносов не попала в раздел IV КУДиР') self.assertAlmostEqual(sum(lines.mapped('expense_amount')), 18000.0, 2)