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