Files
public/rsbu_payroll/tests/test_rsbu_payroll.py

259 lines
12 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 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)