Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
from . import res_company
from . import res_config_settings
from . import rsbu_employee_child
from . import hr_employee
from . import hr_leave_type
from . import hr_salary_rule
from . import hr_payslip
from . import hr_payslip_run
from . import rsbu_rsv_summary
from . import rsbu_ndfl_summary
from . import rsbu_hr_event

View File

@@ -0,0 +1,128 @@
from odoo import fields, models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# --- Персональные данные для отчётности (перс. сведения, РСВ, ЕФС-1,
# 6-НДФЛ). Дата рождения/гражданство берутся из штатных hr-полей
# (birthday, country_id). TODO(сверка форматов/справочников). ---
rsbu_snils = fields.Char(
string='СНИЛС',
help='Страховой номер индивидуального лицевого счёта (формат '
'XXX-XXX-XXX YY). Обязателен для персонифицированной отчётности '
'и ЕФС-1.')
rsbu_inn = fields.Char(
string='ИНН физлица',
help='ИНН сотрудника (12 знаков). Для 6-НДФЛ и РСВ.')
rsbu_insured_category = fields.Char(
string='Код категории застрахованного',
default='НР',
help='Код категории застрахованного лица для РСВ/перс. сведений '
'(напр. «НР» — наёмные работники по единому тарифу, «МС» — '
'выплаты сверх МРОТ по льготному тарифу МСП). TODO(сверка '
'справочника кодов).')
# --- Реквизиты для раздела 3 РСВ (персонифицированные сведения) ---
rsbu_citizenship_code = fields.Char(
string='Гражданство (ОКСМ)', default='643', size=3,
help='Числовой код страны гражданства по ОКСМ (643 — Россия). '
'Атрибут «Гражд» в разделе 3 РСВ.')
rsbu_sex = fields.Selection(
selection=[('1', 'Мужской'), ('2', 'Женский')],
string='Пол',
help='Пол застрахованного лица (атрибут «Пол» в разделе 3 РСВ).')
rsbu_id_doc_code = fields.Char(
string='Код вида документа (ДУЛ)', default='21', size=2,
help='Код вида документа, удостоверяющего личность (21 — паспорт '
'гражданина РФ). Атрибут «КодВидДок» в разделе 3 РСВ.')
rsbu_id_doc_number = fields.Char(
string='Серия и номер документа',
help='Серия и номер документа, удостоверяющего личность (атрибут '
'«СерНомДок» в разделе 3 РСВ), напр. «45 04 123456».')
rsbu_okz_code = fields.Char(
string='Код по ОКЗ', size=6,
help='Кодовое обозначение занятия по Общероссийскому классификатору '
'занятий (ОКЗ), формат NNNN.N (напр. «2512.1»). Элемент '
'«КодВФпоОКЗ» мероприятия ЕФС-1 (СЗВ-ТД); обязателен для приёма, '
'перевода, увольнения и ряда других мероприятий.')
rsbu_taxpayer_status = fields.Selection(
selection=[
('1', 'Резидент РФ'),
('2', 'Нерезидент РФ'),
('3', 'Высококвалифицированный специалист'),
('4', 'Участник госпрограммы переселения'),
('5', 'Беженец / временное убежище'),
('6', 'Иностранец по патенту'),
('7', 'Гражданин ЕАЭС'),
],
string='Статус налогоплательщика', default='1',
help='Статус для 6-НДФЛ и порядка исчисления НДФЛ (ст.224 НК): '
'резидент, ВКС, патент, ЕАЭС — прогрессивная шкала 13–22%; '
'обычный нерезидент — 30% без вычетов. TODO(сверка справочника).')
# --- Стандартные вычеты по НДФЛ (ст. 218 НК) ---
rsbu_child_ids = fields.One2many(
'rsbu.employee.child', 'employee_id', string='Дети (для вычетов)',
help='Дети сотрудника для стандартного вычета по НДФЛ. Очередность '
'и возраст определяют размер вычета; система прекращает вычет '
'по достижении лимита дохода 450 000 ₽ и по возрасту ребёнка.')
rsbu_personal_deduction_code = fields.Selection(
selection=[
('none', 'Нет'),
('104', '500 ₽/мес (код 104)'),
('105', '3000 ₽/мес (код 105)'),
],
string='Личный вычет', default='none',
help='Личный стандартный вычет на работника (пп.1–2 п.1 ст.218 НК): '
'3000 ₽ (чернобыльцы, инвалиды ВОВ), 500 ₽ (Герои, инвалиды '
'I–II гр., ветераны). Лимита по доходу нет.')
rsbu_gto_deduction = fields.Boolean(
string='Вычет ГТО (18 000 ₽/год)',
help='Сотрудник сдал нормативы ГТО и получил знак отличия — разовый '
'годовой вычет 18 000 ₽ (пп.2.1 п.1 ст.218 НК, с 2025).')
def rsbu_is_progressive_ndfl(self):
"""Применяется ли прогрессивная шкала 13–22% (иначе 30% нерезидент).
Прогрессия — резиденты (1), ВКС (3), патент (6), ЕАЭС (7). Обычный
нерезидент (2) — 30% без вычетов. Переселенцы (4) и беженцы (5)
облагаются как резиденты по трудовым доходам — прогрессия. (ст.224 НК)
"""
self.ensure_one()
return self.rsbu_taxpayer_status != '2'
def rsbu_allows_deductions(self):
"""Право на стандартные вычеты (только резиденты-статус для шкалы).
Нерезиденты (в т.ч. ВКС/патент/ЕАЭС до приобретения резидентства)
вычеты не получают (п.3 ст.210, п.3 ст.224 НК). В базовом сценарии
вычеты даём статусу «резидент РФ»; для остальных — TODO по факту
резидентства (нужен учёт дней пребывания).
"""
self.ensure_one()
return self.rsbu_taxpayer_status == '1'
# Данные для расчёта пособий (больничный и т.п.).
# Позже средний заработок будет вычисляться калькулятором за 2 года.
rsbu_insurance_years = fields.Float(
string='Страховой стаж, лет',
default=0.0,
help='Страховой стаж сотрудника. Определяет процент оплаты больничного: '
'≥8 лет — 100%, 5–8 лет — 80%, <5 лет — 60%.')
rsbu_avg_daily_wage = fields.Monetary(
string='Средний дневной заработок',
currency_field='rsbu_currency_id',
help='Средний дневной заработок для расчёта пособий (больничный, '
'отпускные). Пока задаётся вручную; позже — калькулятор за 2 года.')
rsbu_currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id', readonly=True)
def rsbu_sick_rate(self):
"""Процент оплаты больничного по страховому стажу (доля 0..1)."""
self.ensure_one()
years = self.rsbu_insurance_years or 0.0
if years >= 8:
return 1.0
if years >= 5:
return 0.8
return 0.6

View File

@@ -0,0 +1,18 @@
from odoo import fields, models
class HrLeaveType(models.Model):
_inherit = 'hr.leave.type'
rsbu_leave_kind = fields.Selection(
selection=[
('vacation', 'Оплачиваемый отпуск'),
('sick', 'Больничный'),
('unpaid', 'Без сохранения зарплаты'),
('other', 'Прочее'),
],
string='Вид отсутствия (РСБУ)',
default='other',
help='Классификация отсутствия для расчёта зарплаты: определяет, как '
'дни этого типа учитываются в расчётном листке (отпускные, '
'больничный, без оплаты).')

View File

@@ -0,0 +1,656 @@
from odoo import api, fields, models, _
class HrPayslip(models.Model):
"""Расчётный листок + генерация проводки РСБУ при проведении.
Наследует `rsbu.posting.mixin` (Этап 3): по событию проведения
расчётного листка (`action_payslip_done`) идемпотентно создаётся и
проводится операция в журнале РСБУ; при отмене/возврате в черновик —
удаляется. Строки Дт/Кт собираются из строк расчёта по счетам,
заданным на правилах (см. `hr.salary.rule`).
"""
_name = 'hr.payslip'
_inherit = ['hr.payslip', 'rsbu.posting.mixin']
# Данные из hr.leave/календаря для автоматического расчёта (без ручных input).
rsbu_norm_days = fields.Float(
string='Норма рабочих дней', compute='_compute_rsbu_leave_data')
rsbu_worked_days = fields.Float(
string='Отработано дней', compute='_compute_rsbu_leave_data')
rsbu_vacation_cal_days = fields.Float(
string='Кал. дней отпуска', compute='_compute_rsbu_leave_data')
rsbu_sick_cal_days = fields.Float(
string='Кал. дней больничного', compute='_compute_rsbu_leave_data')
rsbu_unpaid_work_days = fields.Float(
string='Раб. дней без содержания', compute='_compute_rsbu_leave_data')
# Дата удержания НДФЛ = дата фактической выплаты дохода. Определяет
# срок перечисления налога для 6-НДФЛ (раздел 1, 6 сроков квартала).
# Заполняется автоматически при проведении из даты выплаты реестра
# (payslip_run_id.payment_date) с фолбэком на последний день периода;
# может быть скорректирована вручную.
rsbu_withholding_date = fields.Date(
string='Дата удержания НДФЛ',
help='Дата фактической выплаты дохода (удержания НДФЛ). Определяет '
'срок перечисления для 6-НДФЛ. По умолчанию — дата выплаты '
'реестра, иначе последний день расчётного периода.')
def _rsbu_default_withholding_date(self):
"""Дата удержания по умолчанию: дата выплаты реестра или конец периода."""
self.ensure_one()
run = self.payslip_run_id
if run and run.payment_date:
return run.payment_date
return self.date_to
@api.depends('employee_id', 'contract_id', 'date_from', 'date_to')
def _compute_rsbu_leave_data(self):
"""Собрать данные об отсутствиях из hr.leave и календаря за период.
Норма рабочих дней по календарю; отработанные = норма минус рабочие
дни всех отсутствий; календарные дни отпуска и больничного — для
расчёта отпускных и пособия. Всё берётся автоматически, без input.
"""
for slip in self:
slip.rsbu_norm_days = 0.0
slip.rsbu_worked_days = 0.0
slip.rsbu_vacation_cal_days = 0.0
slip.rsbu_sick_cal_days = 0.0
slip.rsbu_unpaid_work_days = 0.0
contract = slip.contract_id or slip._get_employee_contracts()[:1]
if not (slip.employee_id and slip.date_from and slip.date_to and contract):
continue
calendar = contract.resource_calendar_id
if not calendar:
continue
# Норма рабочих дней по календарю за период (без вычета отсутствий).
# _get_work_days_data_batch требует datetime (с временем), не date.
from datetime import datetime, time
dt_from = datetime.combine(slip.date_from, time.min)
dt_to = datetime.combine(slip.date_to, time.max)
data = slip.employee_id._get_work_days_data_batch(
dt_from, dt_to, calendar=calendar, compute_leaves=False)
norm = data.get(slip.employee_id.id, {}).get('days', 0.0)
slip.rsbu_norm_days = norm
# Отсутствия сотрудника за период (одобренные), по видам.
leaves = self.env['hr.leave'].search([
('employee_id', '=', slip.employee_id.id),
('state', '=', 'validate'),
('request_date_from', '<=', slip.date_to),
('request_date_to', '>=', slip.date_from),
])
work_leave_days = 0.0 # рабочие дни всех отсутствий
for lv in leaves:
kind = lv.holiday_status_id.rsbu_leave_kind or 'other'
# рабочие дни отсутствия в пределах периода
d_from = max(lv.request_date_from, slip.date_from)
d_to = min(lv.request_date_to, slip.date_to)
cal_days = (d_to - d_from).days + 1
wdays = lv.number_of_days # рабочие дни отсутствия (hr.leave)
work_leave_days += wdays
if kind == 'vacation':
slip.rsbu_vacation_cal_days += cal_days
elif kind == 'sick':
slip.rsbu_sick_cal_days += cal_days
elif kind == 'unpaid':
slip.rsbu_unpaid_work_days += wdays
slip.rsbu_worked_days = max(0.0, norm - work_leave_days)
def action_payslip_done(self):
res = super().action_payslip_done()
for slip in self.filtered(lambda s: s.state == 'done'):
# Проставить дату удержания НДФЛ, если не задана вручную.
if not slip.rsbu_withholding_date:
slip.rsbu_withholding_date = slip._rsbu_default_withholding_date()
slip._rsbu_generate()
return res
def action_payslip_cancel(self):
# Снять РСБУ-проводку до отмены расчётного листка.
self._rsbu_remove()
return super().action_payslip_cancel()
def action_payslip_draft(self):
self._rsbu_remove()
return super().action_payslip_draft()
# ------------------------------------------------------------------
# НДФЛ: прогрессивная шкала 13–22% нарастающим итогом + вычеты
# (ст. 224, 225, 218 НК РФ, 2026). Вызывается правилом NDFL.
# ------------------------------------------------------------------
# Пороги основной базы (₽, нарастающим итогом) и ставки сверх порога.
# 13% до 2.4М, 15% до 5М, 18% до 20М, 20% до 50М, 22% свыше (ст.224 НК).
NDFL_BRACKETS = [
(2_400_000.0, 0.13),
(5_000_000.0, 0.15),
(20_000_000.0, 0.18),
(50_000_000.0, 0.20),
(None, 0.22),
]
NDFL_NONRESIDENT_RATE = 0.30 # обычный нерезидент (п.3 ст.224 НК)
NDFL_CHILD_INCOME_LIMIT = 450_000.0 # лимит дохода для детских вычетов
NDFL_PERSONAL_DEDUCTION = {'104': 500.0, '105': 3000.0}
NDFL_GTO_DEDUCTION = 18_000.0
@api.model
def _ndfl_progressive_tax(self, taxable_base):
"""НДФЛ по прогрессивной шкале от налоговой базы (нарастающим итогом).
Повышенная ставка применяется к сумме превышения порога, не ко
всему доходу (п.1 ст.225 НК). Возвращает исчисленный налог по слоям.
"""
if taxable_base <= 0:
return 0.0
tax = 0.0
lower = 0.0
for upper, rate in self.NDFL_BRACKETS:
if upper is None:
tax += (taxable_base - lower) * rate
break
if taxable_base > upper:
tax += (upper - lower) * rate
lower = upper
else:
tax += (taxable_base - lower) * rate
break
return round(tax, 2)
def _ndfl_prior_gross(self):
"""Облагаемый доход GROSS проведённых листков сотрудника за год
(до текущего листка, нарастающим итогом основной базы)."""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
return sum(
l.total for s in prior
for l in s.line_ids.filtered(lambda x: x.code == 'GROSS'))
def _ndfl_payslip_months(self, include_current=True):
"""Число расчётных месяцев года по фактическим листкам сотрудника.
Стандартные вычеты положены за месяцы налогового периода, в которых
были трудовые отношения (есть расчётный листок), а не с 1 января
формально. Считаем уникальные месяцы (год-месяц) проведённых листков
года до текущего плюс текущий (если include_current).
"""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
months = {(s.date_from.year, s.date_from.month) for s in prior}
if include_current:
months.add((self.date_from.year, self.date_from.month))
return len(months)
def _ndfl_ytd_deductions(self, ytd_total):
"""Стандартные вычеты сотрудника нарастающим итогом с начала года.
Детские вычеты — помесячно, пока доход нарастающим итогом не превысил
450 000 ₽ (в месяц превышения не даётся); личный вычет — помесячно
без лимита дохода; ГТО — разово за год. `ytd_total` — доход основной
базы нарастающим итогом включая текущий месяц.
"""
self.ensure_one()
employee = self.employee_id
if not employee.rsbu_allows_deductions():
return 0.0
# Число расчётных месяцев по фактическим листкам (включая текущий).
months = self._ndfl_payslip_months(include_current=True)
# Детский вычет за месяц (сумма по всем детям).
month_child = sum(
child._deduction_amount(self.date_from)
for child in employee.rsbu_child_ids)
# Число месяцев года, где доход ещё не превысил лимит 450 000 ₽.
# Точная помесячная проверка требует дохода каждого месяца; для
# базового сценария (равномерная зарплата) оцениваем по среднему.
eligible_months = months
if ytd_total > self.NDFL_CHILD_INCOME_LIMIT and months > 0:
avg_per_month = ytd_total / months
if avg_per_month > 0:
eligible_months = min(months, int(
self.NDFL_CHILD_INCOME_LIMIT // avg_per_month))
child_deduction = month_child * max(0, eligible_months)
# Личный вычет (500/3000 ₽) — помесячно без лимита.
personal = self.NDFL_PERSONAL_DEDUCTION.get(
employee.rsbu_personal_deduction_code, 0.0) * months
# Вычет ГТО — разово за год.
gto = self.NDFL_GTO_DEDUCTION if employee.rsbu_gto_deduction else 0.0
return round(child_deduction + personal + gto, 2)
def _ndfl_tax_withheld_before(self):
"""НДФЛ, удержанный по предыдущим листкам года (нарастающий итог)."""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
# NDFL в строках отрицательный (удержание) — берём модуль.
return round(abs(sum(
l.total for s in prior
for l in s.line_ids.filtered(lambda x: x.code == 'NDFL'))), 2)
def rsbu_compute_ndfl(self, current_gross=None):
"""НДФЛ текущего листка (₽, положительное число к удержанию).
Алгоритм (нарастающим итогом с начала года, ст.224/225/218 НК):
1. Доход основной базы YTD = GROSS проведённых листков года +
текущий GROSS;
2. Обычный нерезидент — 30% на текущий GROSS без вычетов и без
нарастающего итога; иначе прогрессивная шкала 13–22% от
(доход YTD − вычеты YTD);
3. Налог месяца = налог с базы YTD − налог, удержанный ранее.
`current_gross` передаётся правилом из localdict (categories.
RSBU_GROSS); если не задан — берётся из строк листка (после расчёта).
Возвращает сумму к удержанию за текущий листок.
"""
self.ensure_one()
employee = self.employee_id
if current_gross is None:
current_gross = sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total'))
# Обычный нерезидент: плоские 30% на текущий доход, без вычетов.
if not employee.rsbu_is_progressive_ndfl():
return round(current_gross * self.NDFL_NONRESIDENT_RATE, 2)
prior_gross = self._ndfl_prior_gross()
ytd_gross = prior_gross + current_gross
deductions = self._ndfl_ytd_deductions(ytd_gross)
taxable = max(0.0, ytd_gross - deductions)
tax_ytd = self._ndfl_progressive_tax(taxable)
# Налог, исчисленный с дохода прошлых месяцев (для разницы месяца).
prior_deductions = self._ndfl_ytd_deductions_prior(prior_gross)
prior_taxable = max(0.0, prior_gross - prior_deductions)
tax_prior = self._ndfl_progressive_tax(prior_taxable)
tax_month = round(tax_ytd - tax_prior, 2)
return max(0.0, tax_month)
def _ndfl_ytd_deductions_prior(self, prior_gross):
"""Вычеты, накопленные к ПРЕДЫДУЩЕМУ месяцу (для разницы налога).
Аналог _ndfl_ytd_deductions, но за месяцы года ДО текущего.
"""
self.ensure_one()
employee = self.employee_id
if not employee.rsbu_allows_deductions():
return 0.0
months_prior = self._ndfl_payslip_months(include_current=False)
if months_prior <= 0:
return 0.0
month_child = sum(
child._deduction_amount(self.date_from)
for child in employee.rsbu_child_ids)
eligible_months = months_prior
if prior_gross > self.NDFL_CHILD_INCOME_LIMIT and months_prior > 0:
avg = prior_gross / months_prior
if avg > 0:
eligible_months = min(months_prior, int(
self.NDFL_CHILD_INCOME_LIMIT // avg))
child_deduction = month_child * max(0, eligible_months)
personal = self.NDFL_PERSONAL_DEDUCTION.get(
employee.rsbu_personal_deduction_code, 0.0) * months_prior
gto = self.NDFL_GTO_DEDUCTION if employee.rsbu_gto_deduction else 0.0
return round(child_deduction + personal + gto, 2)
# ------------------------------------------------------------------
# Показатели 6-НДФЛ по листку (для агрегации rsbu.ndfl.summary)
# ------------------------------------------------------------------
def _ndfl_report_values(self):
"""Показатели листка для 6-НДФЛ: начислено, вычеты, база, исчислено,
удержано, ставка (верхняя применённая), дата удержания.
Начислено (GROSS) и удержанный НДФЛ — из строк листка. Вычеты за
месяц — разница YTD-вычетов. База = начислено − вычеты (не ниже 0).
Исчислено для резидента ≈ удержано месяца. Ставка — верхняя ступень
прогрессивной шкалы, в которую попал доход YTD (для группировки
РасчСумНал по ставке).
"""
self.ensure_one()
gross = round(sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total')), 2)
withheld = round(abs(sum(self.line_ids.filtered(
lambda x: x.code == 'NDFL').mapped('total'))), 2)
employee = self.employee_id
# Вычет месяца = YTD-вычеты (вкл. текущий) − YTD-вычеты до текущего.
if employee.rsbu_allows_deductions():
prior_gross = self._ndfl_prior_gross()
ded_ytd = self._ndfl_ytd_deductions(prior_gross + gross)
ded_prior = self._ndfl_ytd_deductions_prior(prior_gross)
deduction = max(0.0, round(ded_ytd - ded_prior, 2))
else:
deduction = 0.0
base = max(0.0, round(gross - deduction, 2))
rate = self._ndfl_report_rate()
return {
'gross': gross, 'deduction': deduction, 'base': base,
'computed': withheld, 'withheld': withheld, 'rate': rate,
'withholding_date': (
self.rsbu_withholding_date
or self._rsbu_default_withholding_date()),
}
def _ndfl_report_rate(self):
"""Верхняя ставка прогрессивной шкалы по доходу YTD (в процентах).
Для нерезидента — 30%. Для резидента — ставка той ступени, в
которую попадает совокупный доход года (нарастающим итогом).
"""
self.ensure_one()
if not self.employee_id.rsbu_is_progressive_ndfl():
return int(round(self.NDFL_NONRESIDENT_RATE * 100))
gross = round(sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total')), 2)
ytd = self._ndfl_prior_gross() + gross
lower = 0.0
rate = self.NDFL_BRACKETS[0][1]
for upper, r in self.NDFL_BRACKETS:
rate = r
if upper is None or ytd <= upper:
break
lower = upper
return int(round(rate * 100))
# ------------------------------------------------------------------
# Разбивка страховых взносов по видам для РСВ (ОПС/ОМС/ВНиМ)
# ------------------------------------------------------------------
def _rsbu_insurance_gross(self):
"""Начисленные выплаты (GROSS) текущего листка — для РСВ «Выплаты»."""
self.ensure_one()
by_code = {l.code: l.total for l in self.line_ids}
gross = sum(self.line_ids.filtered(
lambda x: x.category_id.code == 'RSBU_GROSS').mapped('total'))
if not gross:
gross = by_code.get('GROSS', 0.0)
return round(gross, 2)
def rsbu_insurance_base(self):
"""База страховых взносов текущего листка (GROSS − необлагаемое).
Необлагаемая часть — больничный (SICK3, не облагается взносами).
Аналогично правилу INSURANCE (base = RSBU_GROSS − SICK3). Читается
из строк листка; если строки ещё не сохранены (момент расчёта),
предпочтительно передавать base явно в rsbu_insurance_breakdown.
"""
self.ensure_one()
by_code = {l.code: l.total for l in self.line_ids}
gross = sum(self.line_ids.filtered(
lambda x: x.category_id.code == 'RSBU_GROSS').mapped('total'))
if not gross:
gross = by_code.get('GROSS', 0.0)
sick = by_code.get('SICK3', 0.0)
return max(0.0, gross - sick)
def _rsbu_insurance_ytd_base(self):
"""База страховых взносов сотрудника с начала года ДО текущего листка.
Нужна для регрессии: годовая предельная база ОПС считается
нарастающим итогом. Суммирует базу (GROSS − SICK3) по проведённым
листкам года, предшествующим текущему.
"""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
total = 0.0
for slip in prior:
total += slip.rsbu_insurance_base()
return round(total, 2)
def rsbu_insurance_breakdown(self, base=None):
"""Разбивка взносов текущего листка по видам для РСВ: dict.
Возвращает {'ops', 'oms', 'vnim', 'total', 'base', 'base_regular',
'base_msp', 'base_within_limit', 'base_over_limit',
'ops_within', 'ops_over'}.
Базовый тариф применяется к части базы в пределах 1.5 МРОТ (при
льготе МСП) или ко всей базе; часть сверх 1.5 МРОТ — по льготному
совокупному тарифу МСП (ОПС/ОМС пропорционально, ВНиМ по льготе = 0).
Регрессия ОПС: годовая предельная база (rsbu_insurance_base_limit)
считается нарастающим итогом с начала года. База текущего листка
делится на часть в пределах лимита (base_within_limit) и сверх
(base_over_limit); взносы ОПС на превышение начисляются по
пониженному тарифу (для единого пониженного МСП сверх лимита — тот
же льготный тариф). ОМС предела не имеет.
"""
self.ensure_one()
company = self.company_id
if base is None:
base = self.rsbu_insurance_base()
empty = {'ops': 0.0, 'oms': 0.0, 'vnim': 0.0, 'total': 0.0,
'base': 0.0, 'base_regular': 0.0, 'base_msp': 0.0,
'base_within_limit': 0.0, 'base_over_limit': 0.0,
'ops_within': 0.0, 'ops_over': 0.0}
if base <= 0:
return empty
r_ops = (company.rsbu_rate_ops or 0.0) / 100.0
r_oms = (company.rsbu_rate_oms or 0.0) / 100.0
r_vnim = (company.rsbu_rate_vnim or 0.0) / 100.0
# --- Разбивка базы по годовой предельной базе (регрессия ОПС) ---
limit = company.rsbu_insurance_base_limit or 0.0
if limit > 0:
ytd = self._rsbu_insurance_ytd_base()
remaining = max(0.0, limit - ytd) # ещё в пределах лимита
base_within_limit = min(base, remaining)
base_over_limit = max(0.0, base - base_within_limit)
else:
base_within_limit = base
base_over_limit = 0.0
# --- Разбивка части В ПРЕДЕЛАХ лимита по порогу 1.5 МРОТ (МСП) ---
base_regular = base
base_msp = 0.0
if company.rsbu_msp_enabled and company.rsbu_min_wage:
threshold = 1.5 * company.rsbu_min_wage
base_regular = min(base, threshold)
base_msp = max(0.0, base - threshold)
# ОМС/ВНиМ считаем по базе в пределах 1.5 МРОТ (обычный тариф) плюс
# льготная часть МСП (ОМС — да, ВНиМ по льготе = 0). Предел базы
# к ОМС не применяется.
oms = base_regular * r_oms
vnim = base_regular * r_vnim
# ОПС в пределах лимита: часть base_within_limit по обычному тарифу
# (в пределах 1.5 МРОТ) + льготному (сверх 1.5 МРОТ).
# Для простоты и корректности начисления считаем ОПС отдельно по
# обычной и льготной частям всей базы, затем разносим на within/over.
ops_regular = base_regular * r_ops
ops_msp = 0.0
if base_msp:
r_msp = (company.rsbu_msp_rate or 0.0) / 100.0
msp_total = base_msp * r_msp
share = (r_ops + r_oms) or 1.0
ops_msp = msp_total * (r_ops / share)
oms += msp_total * (r_oms / share)
ops_total = ops_regular + ops_msp
# Разнесение ОПС на within/over по доле базы в пределах лимита.
if base > 0:
frac_within = base_within_limit / base
else:
frac_within = 1.0
ops_within = round(ops_total * frac_within, 2)
ops_over = round(ops_total - ops_within, 2)
return {
'ops': round(ops_total, 2),
'oms': round(oms, 2),
'vnim': round(vnim, 2),
'total': round(ops_total + oms + vnim, 2),
'base': round(base, 2),
'base_regular': round(base_regular, 2),
'base_msp': round(base_msp, 2),
'base_within_limit': round(base_within_limit, 2),
'base_over_limit': round(base_over_limit, 2),
'ops_within': ops_within,
'ops_over': ops_over,
}
# ------------------------------------------------------------------
# Учёт отсутствий из hr.leave: раздельные коды по виду отсутствия
# ------------------------------------------------------------------
# Код строки worked_days по виду отсутствия (rsbu_leave_kind):
RSBU_LEAVE_CODE = {
'vacation': 'LV_VACATION',
'sick': 'LV_SICK',
'unpaid': 'LV_UNPAID',
'other': 'LV_OTHER',
}
def _compute_leave_days(self, contract, day_from, day_to):
"""Строки отсутствий с раздельными кодами по виду (РСБУ).
Штатный payroll сваливает все отсутствия в код GLOBAL с
отрицательным числом дней. Нам нужно различать отпуск/больничный/
без содержания, чтобы правила расчёта тянули данные автоматически.
Формируем по одной строке worked_days на КАЖДЫЙ вид отсутствия
(rsbu_leave_kind типа отсутствия), с ПОЛОЖИТЕЛЬНЫМ числом дней/часов —
так формулам удобнее (дни отсутствия каждого вида).
"""
self.ensure_one()
from pytz import timezone
from datetime import datetime, time
leaves = {}
calendar = contract.resource_calendar_id
tz = timezone(calendar.tz or contract.employee_id.tz or 'UTC')
day_leave_intervals = contract.employee_id.list_leaves(
day_from, day_to, calendar=calendar)
for day, hours, leave in day_leave_intervals:
holiday = leave[:1].holiday_id
leave_type = holiday.holiday_status_id
kind = leave_type.rsbu_leave_kind or 'other'
code = self.RSBU_LEAVE_CODE.get(kind, 'LV_OTHER')
struct = leaves.setdefault(code, {
'name': leave_type.name or _('Отсутствие'),
'sequence': 20,
'code': code,
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract.id,
})
struct['number_of_hours'] += hours
work_hours = calendar.get_work_hours_count(
tz.localize(datetime.combine(day, time.min)),
tz.localize(datetime.combine(day, time.max)),
compute_leaves=False)
if work_hours:
struct['number_of_days'] += hours / work_hours
return list(leaves.values())
# ------------------------------------------------------------------
# Точки расширения rsbu.posting.mixin
# ------------------------------------------------------------------
def _rsbu_is_applicable(self):
"""Проводку формируем для проведённого расчётного листка."""
self.ensure_one()
return self.state == 'done'
def _rsbu_move_date(self):
"""Дата проводки начисления — конец расчётного периода."""
self.ensure_one()
return self.date_to or fields.Date.context_today(self)
def _rsbu_get_move_values(self):
vals = super()._rsbu_get_move_values()
# Начисление зарплаты в КУДиР (кассовый метод) не участвует —
# признание идёт при выплате/уплате. Помечаем, чтобы пересчёт
# КУДиР не подхватывал начисление.
vals['usn_recognition'] = 'none'
return vals
def _rsbu_build_lines(self):
"""Собрать строки rsbu.move.line из строк расчёта.
По каждому правилу с суммой и заданными счетами РСБУ формируем
две строки (дебет и кредит) на модуль суммы. Отрицательные суммы
(удержания) и режим сторно (`credit_note`) инвертируют стороны.
Субконто «сотрудник» проставляется, где включено на правиле
(обязательно для счёта 70).
"""
self.ensure_one()
lines = []
employee = self.employee_id
for pline in self.line_ids:
rule = pline.salary_rule_id
debit_account = rule.rsbu_account_debit
credit_account = rule.rsbu_account_credit
# Правило без счетов РСБУ не порождает проводку (напр. GROSS/NET —
# промежуточные итоги, счетов не имеют).
if not debit_account and not credit_account:
continue
# Направление проводки задаётся счетами правила (Дт/Кт), а сумма —
# модуль строки расчёта. Знак total лишь отражает роль правила в
# расчёте NET (удержания заданы «минусом»), но не направление
# проводки. Сторно (credit_note) инвертирует стороны.
amount = abs(pline.total or 0.0)
if not amount:
continue
invert = bool(self.credit_note)
base_subconto = {}
if rule.rsbu_subconto_employee and employee:
base_subconto['employee_id'] = employee.id
if debit_account:
lines.append(self._rsbu_payroll_line(
debit_account, pline.name,
debit=0.0 if invert else amount,
credit=amount if invert else 0.0,
subconto=base_subconto))
if credit_account:
lines.append(self._rsbu_payroll_line(
credit_account, pline.name,
debit=amount if invert else 0.0,
credit=0.0 if invert else amount,
subconto=base_subconto))
return lines
def _rsbu_payroll_line(self, account, name, debit=0.0, credit=0.0, subconto=None):
"""Хелпер значений строки rsbu.move.line.
Субконто «сотрудник» на счёте требуется планом счетов только для 70;
проставляем его на всех строках с включённым флагом (безопасно —
для прочих счетов это просто аналитика).
"""
vals = {
'account_id': account.id,
'name': name or _('Начисление зарплаты'),
'debit': debit,
'credit': credit,
}
if subconto:
vals.update(subconto)
# Счёт может обязательно требовать субконто «сотрудник» (напр. 70):
# если правило не проставило employee, но счёт требует — подставим.
if 'employee_id' not in vals and account.subconto_employee and self.employee_id:
vals['employee_id'] = self.employee_id.id
return vals

View File

@@ -0,0 +1,160 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class HrPayslipRun(models.Model):
"""Реестр зарплаты (зарплатный проект банка).
Расширяет штатный «Пакет расчётных листков» до реестра на выплату:
банк одним платежом зачисляет общую сумму сотрудникам (в выписке одна
строка от банка), а реестр раскладывает её на выплаты по каждому
сотруднику (Дт 70 — Кт 51) одной кнопкой.
"""
_inherit = 'hr.payslip.run'
payment_bank_partner_id = fields.Many2one(
'res.partner', string='Банк (зарплатный проект)',
help='Контрагент-банк зарплатного проекта (напр. Т-Банк), от которого '
'приходит единый платёж по реестру.')
payment_date = fields.Date(
string='Дата выплаты',
default=fields.Date.context_today)
bank_fee_amount = fields.Monetary(
string='Комиссия банка', currency_field='currency_id', default=0.0)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id', readonly=True)
total_net = fields.Monetary(
string='Итого к перечислению', currency_field='currency_id',
compute='_compute_total_net', store=False,
help='Сумма «к выплате» (правило NET) по проведённым расчётным '
'листкам реестра.')
rsbu_payout_move_id = fields.Many2one(
'rsbu.move', string='РСБУ-проводка выплаты', copy=False, readonly=True)
payout_state = fields.Selection(
[('to_pay', 'К выплате'), ('paid', 'Выплачено')],
string='Статус выплаты', default='to_pay', copy=False, readonly=True)
@api.depends('slip_ids.line_ids.total', 'slip_ids.state')
def _compute_total_net(self):
for run in self:
total = 0.0
for slip in run.slip_ids.filtered(lambda s: s.state == 'done'):
total += run._slip_net(slip)
run.total_net = total
@staticmethod
def _slip_net(slip):
"""Сумма «к выплате» по расчётному листку (правило NET)."""
net_lines = slip.line_ids.filtered(lambda l: l.code == 'NET')
return sum(net_lines.mapped('total'))
# ------------------------------------------------------------------
# Проводки выплаты
# ------------------------------------------------------------------
def _rsbu_account(self, code):
"""Найти счёт РСБУ по коду в разрезе компании реестра."""
self.ensure_one()
account = self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.company_id.id),
], limit=1)
if not account:
raise UserError(_(
'В плане счетов РСБУ компании «%(company)s» не найден счёт %(code)s.',
company=self.company_id.display_name, code=code))
return account
def action_rsbu_pay(self):
"""Сформировать РСБУ-проводку выплаты по реестру (Дт 70 — Кт 51)."""
for run in self:
if run.rsbu_payout_move_id:
# Идемпотентность: повторное нажатие не дублирует.
continue
done_slips = run.slip_ids.filtered(lambda s: s.state == 'done')
if not done_slips:
raise UserError(_(
'В реестре «%s» нет проведённых расчётных листков.')
% run.display_name)
acc_70 = run._rsbu_account('70')
acc_51 = run._rsbu_account('51')
lines = []
total_pay = 0.0
for slip in done_slips:
net = run._slip_net(slip)
if not net:
continue
if not slip.employee_id:
raise UserError(_(
'В расчётном листке %s не указан сотрудник.') % slip.number)
# Дт 70 — закрытие задолженности перед сотрудником.
lines.append({
'account_id': acc_70.id,
'name': _('Выплата зарплаты: %s') % slip.employee_id.name,
'debit': net,
'employee_id': slip.employee_id.id,
})
total_pay += net
if not lines:
raise UserError(_(
'Нет сумм к выплате по реестру «%s».') % run.display_name)
fee = run.bank_fee_amount or 0.0
# Кт 51 — единый платёж банка (сумма выплат + комиссия).
lines.append({
'account_id': acc_51.id,
'name': _('Перечисление зарплаты (реестр %s)') % run.name,
'credit': total_pay + fee,
})
if fee:
acc_fee = run._rsbu_account('91')
lines.append({
'account_id': acc_fee.id,
'name': _('Комиссия банка по реестру %s') % run.name,
'debit': fee,
})
usn_recognition = (
'expense'
if run.company_id.rsbu_usn_mode == 'usn_income_expense'
else 'none')
move = self.env['rsbu.move'].sudo().create({
'date': run.payment_date or fields.Date.context_today(run),
'company_id': run.company_id.id,
'ref': _('Выплата зарплаты по реестру %s') % run.name,
'source_model': run._name,
'source_res_id': run.id,
'usn_recognition': usn_recognition,
'line_ids': [(0, 0, line) for line in lines],
})
move.action_post()
run.write({
'rsbu_payout_move_id': move.id,
'payout_state': 'paid',
})
return True
def action_rsbu_pay_cancel(self):
"""Отменить проводку выплаты (сторно/удаление)."""
for run in self:
move = run.rsbu_payout_move_id.sudo()
if move:
run.rsbu_payout_move_id = False
run.payout_state = 'to_pay'
if move.state == 'posted':
move.action_draft()
move.unlink()
return True
def action_view_rsbu_payout_move(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('РСБУ-проводка выплаты'),
'res_model': 'rsbu.move',
'view_mode': 'form',
'res_id': self.rsbu_payout_move_id.id,
}

View File

@@ -0,0 +1,29 @@
from odoo import fields, models
class HrSalaryRule(models.Model):
"""Правило расчёта зарплаты + счета РСБУ для проводки.
По образцу OCA `payroll_account`, но счета — из плана счетов РСБУ
(`rsbu.account`), а не из нативного `account.account`. Схема проводки
задаётся декларативно на каждом правиле: дебет/кредит.
"""
_inherit = 'hr.salary.rule'
rsbu_account_debit = fields.Many2one(
'rsbu.account',
string='РСБУ счёт (дебет)',
company_dependent=True,
domain="[('is_group', '=', False)]",
help='Счёт РСБУ по дебету для строки проводки этого правила.')
rsbu_account_credit = fields.Many2one(
'rsbu.account',
string='РСБУ счёт (кредит)',
company_dependent=True,
domain="[('is_group', '=', False)]",
help='Счёт РСБУ по кредиту для строки проводки этого правила.')
rsbu_subconto_employee = fields.Boolean(
string='Субконто «Сотрудник»',
default=True,
help='Проставлять сотрудника расчётного листка субконто в строках '
'проводки этого правила.')

View File

@@ -0,0 +1,64 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
# ВНИМАНИЕ: значения-заглушки. Перед вводом в эксплуатацию сверить
# ставки с актуальной нормативкой 2026 (НДФЛ, единый тариф, льготный
# тариф МСП, тариф травматизма по классу профриска). TODO(сверка).
rsbu_ndfl_rate = fields.Float(
string='Ставка НДФЛ, %', default=13.0,
help='Базовая ставка НДФЛ (справочно). С 2025 НДФЛ считается по '
'прогрессивной шкале 13–22% нарастающим итогом с учётом вычетов '
'(см. hr.payslip.rsbu_compute_ndfl); это поле в расчёте не '
'используется, оставлено для совместимости/справки.')
rsbu_insurance_rate = fields.Float(
string='Тариф страховых взносов (единый), %', default=30.0,
help='Единый тариф страховых взносов в пределах предельной базы. '
'Разбивка на ОПС/ОМС/ВНиМ для РСВ — по долям ниже.')
# --- Разбивка единого тарифа на виды (для РСВ). Доли в пределах базы;
# сумма должна давать rsbu_insurance_rate. Значения по НК: ОПС 22 +
# ОМС 5.1 + ВНиМ 2.9 = 30%. TODO(сверка на 2026). ---
rsbu_rate_ops = fields.Float(
string='Тариф ОПС, %', default=22.0,
help='Взносы на обязательное пенсионное страхование (в пределах базы).')
rsbu_rate_oms = fields.Float(
string='Тариф ОМС, %', default=5.1,
help='Взносы на обязательное медицинское страхование.')
rsbu_rate_vnim = fields.Float(
string='Тариф ВНиМ, %', default=2.9,
help='Взносы на случай временной нетрудоспособности и материнства.')
# --- Льготный тариф МСП (сверх 1.5 МРОТ) и предельная база. ---
rsbu_msp_enabled = fields.Boolean(
string='Льготный тариф МСП',
help='Субъект МСП: выплаты сверх 1.5 МРОТ в месяц облагаются по '
'льготному тарифу (ниже). TODO(сверка права на льготу).')
rsbu_msp_rate = fields.Float(
string='Льготный тариф МСП, %', default=15.0,
help='Совокупный тариф для выплат сверх 1.5 МРОТ (МСП). TODO(сверка).')
rsbu_min_wage = fields.Monetary(
string='МРОТ', currency_field='currency_id',
help='Минимальный размер оплаты труда для порога МСП (1.5 МРОТ) и '
'иных расчётов. TODO(актуальный МРОТ на 2026).')
rsbu_insurance_base_limit = fields.Monetary(
string='Предельная база взносов (год)', currency_field='currency_id',
help='Предельная величина базы для страховых взносов нарастающим '
'итогом с начала года; сверх неё ОПС/ВНиМ по пониженному '
'тарифу. TODO(предельная база на 2026).')
rsbu_injury_rate = fields.Float(
string='Тариф взносов на травматизм, %', default=0.2,
help='Тариф взносов на травматизм по классу профриска.')
# --- Код тарифа плательщика для РСВ (раздел 1, РасчСВ_ОПС_ОМС/ТарифПлат
# и код категории застрахованного). ---
rsbu_payer_tariff_code = fields.Char(
string='Код тарифа плательщика (РСВ)', default='01', size=2,
help='Код тарифа плательщика страховых взносов для РСВ (атрибут '
'«ТарифПлат», справочник формата 5.08: 01, 06, 07, 10, 11, '
'13–19 и т.д.). Для субъекта МСП на едином пониженном тарифе '
'выберите соответствующий код из справочника ФНС. '
'TODO(сверка справочника кодов тарифа).')

View File

@@ -0,0 +1,30 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
rsbu_ndfl_rate = fields.Float(
related='company_id.rsbu_ndfl_rate', readonly=False)
rsbu_insurance_rate = fields.Float(
related='company_id.rsbu_insurance_rate', readonly=False)
rsbu_injury_rate = fields.Float(
related='company_id.rsbu_injury_rate', readonly=False)
rsbu_rate_ops = fields.Float(
related='company_id.rsbu_rate_ops', readonly=False)
rsbu_rate_oms = fields.Float(
related='company_id.rsbu_rate_oms', readonly=False)
rsbu_rate_vnim = fields.Float(
related='company_id.rsbu_rate_vnim', readonly=False)
rsbu_msp_enabled = fields.Boolean(
related='company_id.rsbu_msp_enabled', readonly=False)
rsbu_msp_rate = fields.Float(
related='company_id.rsbu_msp_rate', readonly=False)
rsbu_min_wage = fields.Monetary(
related='company_id.rsbu_min_wage', readonly=False)
rsbu_insurance_base_limit = fields.Monetary(
related='company_id.rsbu_insurance_base_limit', readonly=False)
rsbu_payer_tariff_code = fields.Char(
related='company_id.rsbu_payer_tariff_code', readonly=False)
company_currency_id = fields.Many2one(
related='company_id.currency_id', readonly=True)

View File

@@ -0,0 +1,84 @@
"""Дети сотрудника для стандартного вычета по НДФЛ (ст. 218 НК РФ).
Размер вычета зависит от очерёдности рождения и возраста ребёнка;
вычет на ребёнка-инвалида суммируется с вычетом по очерёдности. Право
на вычет прекращается по достижении возраста (18 лет, либо 24 при очной
форме обучения) и при превышении дохода 450 000 ₽ нарастающим итогом
(проверяется при расчёте НДФЛ, не здесь).
"""
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
# Размеры детских вычетов по очерёдности (₽/мес), с 2025 (пп.4 п.1 ст.218 НК).
CHILD_DEDUCTION_BY_ORDER = {1: 1400.0, 2: 2800.0, 3: 6000.0}
# Вычет на ребёнка-инвалида (₽/мес): родитель/усыновитель — 12000,
# опекун/попечитель/приёмный — 6000.
DISABLED_DEDUCTION_PARENT = 12000.0
DISABLED_DEDUCTION_GUARDIAN = 6000.0
class RsbuEmployeeChild(models.Model):
_name = 'rsbu.employee.child'
_description = 'РСБУ — Ребёнок сотрудника (вычет НДФЛ)'
_order = 'birthdate, id'
employee_id = fields.Many2one(
'hr.employee', string='Сотрудник', required=True,
ondelete='cascade', index=True)
name = fields.Char(string='ФИО ребёнка')
birthdate = fields.Date(string='Дата рождения', required=True)
order = fields.Integer(
string='Очерёдность', required=True, default=1,
help='Порядковый номер ребёнка по рождению (1-й, 2-й, 3-й…). '
'Определяет размер вычета: 1400 / 2800 / 6000 ₽. Учитываются '
'все дети, включая совершеннолетних, при определении очерёдности.')
is_disabled = fields.Boolean(
string='Ребёнок-инвалид',
help='Добавляет вычет 12 000 ₽ (родителю/усыновителю) или 6 000 ₽ '
'(опекуну), суммируется с вычетом по очерёдности.')
is_student = fields.Boolean(
string='Очная форма обучения',
help='Продлевает право на вычет до 24 лет (иначе — до 18 лет).')
is_guardian = fields.Boolean(
string='Опекун/попечитель',
help='Отмечено — применяется вычет на ребёнка-инвалида 6 000 ₽ '
'(опекун), иначе 12 000 ₽ (родитель/усыновитель).')
single_parent = fields.Boolean(
string='Единственный родитель',
help='Двойной вычет единственному родителю (пп.4 п.1 ст.218 НК).')
def _age_on(self, date):
"""Полных лет ребёнку на указанную дату."""
self.ensure_one()
if not self.birthdate:
return 0
return relativedelta(date, self.birthdate).years
def _is_eligible_on(self, date):
"""Даёт ли ребёнок право на вычет на указанную дату (по возрасту)."""
self.ensure_one()
age = self._age_on(date)
limit = 24 if self.is_student else 18
return age < limit
def _deduction_amount(self, date):
"""Сумма месячного вычета по ребёнку на дату (по очерёдности +
инвалидность), с учётом двойного вычета единственному родителю."""
self.ensure_one()
if not self._is_eligible_on(date):
return 0.0
amount = CHILD_DEDUCTION_BY_ORDER.get(
self.order, CHILD_DEDUCTION_BY_ORDER[3]) if self.order else 0.0
if self.is_disabled:
amount += (DISABLED_DEDUCTION_GUARDIAN if self.is_guardian
else DISABLED_DEDUCTION_PARENT)
if self.single_parent:
amount *= 2.0
return amount
@api.depends('name', 'order', 'birthdate')
def _compute_display_name(self):
for rec in self:
rec.display_name = '%s (%s, №%s)' % (
rec.name or _('Ребёнок'), rec.birthdate or '?', rec.order or 0)

View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
"""Кадровое мероприятие для ЕФС-1 подраздел 1.1 (СЗВ-ТД).
Одно мероприятие кадрового учёта (приём, перевод, увольнение и т.п.) по
сотруднику — источник для элемента «Мероприятие» (тип ЕФС:ТипТД) формы
ЕФС-1 подраздел 1.1 «Сведения о трудовой (иной) деятельности».
ЕФС-1 (СФР) — событийная форма: подаётся не за период, а по факту кадрового
события. При приёме и увольнении — не позднее следующего рабочего дня, при
прочих мероприятиях — до 25-го числа следующего месяца. Поэтому мероприятие
регистрируется отдельной записью и затем выгружается в документ ЕФС-1
(rsbu.efs1.szvtd).
Поля и справочники — строго по XSD ЕФС:ТипТД (ТипыЕФС_2024-01-01):
UUID, Дата, Вид (1..10), Должность, ЯвляетсяСовместителем, ВидПР,
СтруктурноеПодразделение, КодВФ, КодВФпоОКЗ, ОснованиеУвольнения
(КодПричиныУвольнения, НормативныйДокумент, Статья, Часть, Пункт, Подпункт,
Абзац), Причина, Основание (Наименование, Дата, Серия, Номер).
"""
import uuid
from odoo import api, fields, models
# Виды мероприятий (ЕФС:ТипТД/Вид). Значения 1..10 по XSD.
EVENT_KIND_SELECTION = [
('1', 'ПРИЕМ'),
('2', 'ПЕРЕВОД'),
('3', 'ПЕРЕИМЕНОВАНИЕ'),
('4', 'УСТАНОВЛЕНИЕ (ПРИСВОЕНИЕ)'),
('5', 'УВОЛЬНЕНИЕ'),
('6', 'ЗАПРЕТ ЗАНИМАТЬ ДОЛЖНОСТЬ'),
('7', 'ПРИОСТАНОВЛЕНИЕ'),
('8', 'ВОЗОБНОВЛЕНИЕ'),
('9', 'НАЧАЛО ДОГОВОРА ГПХ'),
('10', 'ОКОНЧАНИЕ ДОГОВОРА ГПХ'),
]
# Виды мероприятий, для которых КодВФпоОКЗ обязателен (по описанию XSD).
KINDS_REQUIRE_OKZ = {'1', '2', '4', '5', '7', '8', '9', '10'}
# Признак совместительства / срочности (ЕФС:ТипТД/ЯвляетсяСовместителем).
EMPLOYMENT_TYPE_SELECTION = [
('0', 'Бессрочный трудовой договор'),
('0.1', 'Срочный ТД до 6 месяцев'),
('0.2', 'Срочный ТД более 6 месяцев'),
('1', 'Совместительство'),
('1.1', 'Совместительство, срочный до 6 месяцев'),
('1.2', 'Совместительство, срочный более 6 месяцев'),
]
class RsbuHrEvent(models.Model):
_name = 'rsbu.hr.event'
_description = 'РСБУ — Кадровое мероприятие (ЕФС-1, СЗВ-ТД)'
_order = 'event_date desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
employee_id = fields.Many2one(
'hr.employee', string='Сотрудник', required=True, index=True,
check_company=True)
event_uuid = fields.Char(
string='UUID мероприятия', required=True, copy=False, index=True,
default=lambda self: str(uuid.uuid4()),
help='Уникальный идентификатор мероприятия (элемент UUID). '
'Используется СФР для сопоставления и отмены мероприятий.')
event_date = fields.Date(
string='Дата мероприятия', required=True,
default=fields.Date.context_today,
help='Дата проведения кадрового мероприятия (приёма, увольнения '
'и т.п.).')
kind = fields.Selection(
selection=EVENT_KIND_SELECTION, string='Вид мероприятия',
required=True, default='1')
# Сведения о занятости.
job_title = fields.Char(
string='Должность',
help='Должность, профессия, специальность, квалификация (элемент '
'Должность).')
employment_type = fields.Selection(
selection=EMPLOYMENT_TYPE_SELECTION, string='Характер работы',
default='0',
help='Признак совместительства/срочности трудового договора '
'(элемент ЯвляетсяСовместителем).')
work_kind = fields.Char(
string='Вид поручаемой работы',
help='Конкретный вид поручаемой работы (элемент ВидПР).')
department_name = fields.Char(
string='Структурное подразделение',
help='Структурное подразделение (элемент СтруктурноеПодразделение).')
func_code = fields.Char(
string='Код выполняемой функции',
help='Код выполняемой функции (элемент КодВФ): особый код трудовой '
'функции (ДИСТ, ДГПХ и т.п.) либо код по профстандарту.')
okz_code = fields.Char(
string='Код по ОКЗ',
help='Кодовое обозначение занятия по ОКЗ, формат NNNN.N (элемент '
'КодВФпоОКЗ). По умолчанию берётся из карточки сотрудника.')
# Документ-основание (приказ).
basis_name = fields.Char(
string='Документ-основание', default='Приказ',
help='Наименование документа-основания (элемент Основание/'
'Наименование), напр. «Приказ».')
basis_date = fields.Date(
string='Дата основания',
help='Дата документа-основания (элемент Основание/Дата).')
basis_series = fields.Char(
string='Серия основания',
help='Серия документа-основания (элемент Основание/Серия).')
basis_number = fields.Char(
string='Номер основания',
help='Номер документа-основания (элемент Основание/Номер).')
# Основание увольнения (только для вида «УВОЛЬНЕНИЕ»).
dismissal_reason_code = fields.Char(
string='Код причины увольнения',
help='Код причины увольнения (элемент ОснованиеУвольнения/'
'КодПричиныУвольнения), напр. «п3ч1с77тк».')
dismissal_document = fields.Char(
string='Нормативный документ', default='Трудовой кодекс Российской Федерации',
help='Наименование нормативного документа (элемент '
'ОснованиеУвольнения/НормативныйДокумент).')
dismissal_article = fields.Char(
string='Статья',
help='Номер статьи нормативного акта (элемент ОснованиеУвольнения/'
'Статья).')
dismissal_part = fields.Char(
string='Часть',
help='Часть статьи (элемент ОснованиеУвольнения/Часть).')
dismissal_point = fields.Char(
string='Пункт',
help='Пункт статьи (элемент ОснованиеУвольнения/Пункт).')
dismissal_subpoint = fields.Char(
string='Подпункт',
help='Подпункт статьи (элемент ОснованиеУвольнения/Подпункт).')
dismissal_paragraph = fields.Char(
string='Абзац',
help='Абзац статьи (элемент ОснованиеУвольнения/Абзац).')
dismissal_reason = fields.Char(
string='Причина увольнения',
help='Текстовая формулировка причины увольнения (элемент Причина).')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('reported', 'Подано')],
default='draft', string='Состояние', copy=False)
@api.depends('employee_id', 'kind', 'event_date')
def _compute_name(self):
labels = dict(EVENT_KIND_SELECTION)
for rec in self:
label = labels.get(rec.kind, '?')
emp = rec.employee_id.name or '?'
dt = rec.event_date and rec.event_date.strftime('%d.%m.%Y') or '?'
rec.name = '%s — %s (%s)' % (label, emp, dt)
@api.onchange('employee_id')
def _onchange_employee_id(self):
"""Подставить должность/подразделение/ОКЗ из карточки сотрудника."""
for rec in self:
emp = rec.employee_id
if not emp:
continue
if not rec.job_title:
rec.job_title = emp.job_title or (
emp.job_id.name if emp.job_id else False)
if not rec.department_name:
rec.department_name = (
emp.department_id.name if emp.department_id else False)
if not rec.okz_code:
rec.okz_code = emp.rsbu_okz_code or False
@api.onchange('kind')
def _onchange_kind(self):
"""Подставить дату основания из даты мероприятия, если пусто."""
for rec in self:
if not rec.basis_date and rec.event_date:
rec.basis_date = rec.event_date
def _okz_required(self):
self.ensure_one()
return self.kind in KINDS_REQUIRE_OKZ
def _effective_okz(self):
"""Код ОКЗ мероприятия (из записи или карточки сотрудника)."""
self.ensure_one()
return self.okz_code or self.employee_id.rsbu_okz_code or ''

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""Регистр помесячных итогов НДФЛ по сотрудникам (для 6-НДФЛ).
Аналог rsbu.rsv.summary: агрегирует проведённые расчётные листки по
(сотрудник × месяц начисления × ставка), вычисляя начисленный доход,
вычеты, базу, исчисленный и удержанный НДФЛ. Дополнительно фиксирует
дату удержания (= дату выплаты) и номер срока перечисления внутри
квартала (1..6), что требуется для раздела 1 формы 6-НДФЛ (КНД 1151100,
формат 5.05): НДФЛ разбивается по шести срокам квартала (каждый месяц —
два периода: дни 1–22 и 23–конец).
"""
from odoo import api, fields, models
class RsbuNdflSummary(models.Model):
_name = 'rsbu.ndfl.summary'
_description = 'РСБУ — Итоги НДФЛ по сотрудникам (6-НДФЛ)'
_order = 'year desc, month, employee_id'
_check_company_auto = True
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
employee_id = fields.Many2one(
'hr.employee', string='Сотрудник', required=True, index=True)
year = fields.Integer(string='Год', required=True, index=True)
month = fields.Integer(string='Месяц начисления', required=True, index=True)
rate = fields.Integer(string='Ставка НДФЛ, %', required=True, index=True)
currency_id = fields.Many2one(
'res.currency', string='Валюта',
compute='_compute_currency_id', store=True)
accrued = fields.Monetary(string='Начислено (доход)',
currency_field='currency_id')
deduction = fields.Monetary(string='Вычеты',
currency_field='currency_id')
base = fields.Monetary(string='Налоговая база',
currency_field='currency_id')
computed_tax = fields.Monetary(string='Исчислено НДФЛ',
currency_field='currency_id')
withheld_tax = fields.Monetary(string='Удержано НДФЛ',
currency_field='currency_id')
# Удержание: дата удержания (= дата выплаты) и срок квартала (1..6).
withholding_date = fields.Date(string='Дата удержания')
withholding_month = fields.Integer(
string='Месяц удержания',
help='Календарный месяц даты удержания (для разбивки по срокам).')
deadline_term = fields.Integer(
string='Срок квартала (1..6)',
help='Номер срока перечисления внутри квартала удержания: по два '
'на месяц (дни 1–22 → нечётный, 23–конец → чётный).')
@api.depends('company_id')
def _compute_currency_id(self):
for rec in self:
rec.currency_id = rec.company_id.currency_id
# ------------------------------------------------------------------
@staticmethod
def _deadline_term(wh_date):
"""Номер срока перечисления внутри КВАРТАЛА (1..6) по дате удержания.
Квартал делится на 6 сроков: по два на каждый месяц квартала —
дни 1–22 (первый срок месяца) и 23–конец (второй срок месяца).
Для месяца-в-квартале k (1..3): срок = (k-1)*2 + (1 если день<=22
иначе 2).
"""
month_in_quarter = (wh_date.month - 1) % 3 + 1
half = 1 if wh_date.day <= 22 else 2
return (month_in_quarter - 1) * 2 + half
@api.model
def recompute_period(self, date_from, date_to, company):
"""Пересобрать помесячные итоги НДФЛ за период из листков.
Идемпотентно: удаляет итоги месяцев периода и заново агрегирует
проведённые листки (state='done') по сотруднику, месяцу начисления
и ставке. Срок удержания берётся по дате удержания листка.
"""
self.search([
('company_id', '=', company.id),
('year', '>=', date_from.year),
('year', '<=', date_to.year),
]).filtered(lambda r: date_from <= fields.Date.to_date(
'%04d-%02d-01' % (r.year, r.month)) <= date_to).unlink()
slips = self.env['hr.payslip'].sudo().search([
('company_id', '=', company.id),
('state', '=', 'done'),
('date_from', '>=', date_from),
('date_from', '<=', date_to),
])
acc = {}
for slip in slips:
v = slip._ndfl_report_values()
if not (v['gross'] or v['withheld']):
continue
key = (slip.employee_id.id, slip.date_from.year,
slip.date_from.month, v['rate'])
wh = v['withholding_date']
data = acc.setdefault(key, {
'accrued': 0.0, 'deduction': 0.0, 'base': 0.0,
'computed': 0.0, 'withheld': 0.0,
'wh_date': wh, 'wh_month': wh.month if wh else 0,
'term': self._deadline_term(wh) if wh else 0,
})
data['accrued'] += v['gross']
data['deduction'] += v['deduction']
data['base'] += v['base']
data['computed'] += v['computed']
data['withheld'] += v['withheld']
vals = []
for (emp, y, m, rate), d in acc.items():
vals.append({
'company_id': company.id, 'employee_id': emp,
'year': y, 'month': m, 'rate': rate,
'accrued': round(d['accrued'], 2),
'deduction': round(d['deduction'], 2),
'base': round(d['base'], 2),
'computed_tax': round(d['computed'], 2),
'withheld_tax': round(d['withheld'], 2),
'withholding_date': d['wh_date'],
'withholding_month': d['wh_month'],
'deadline_term': d['term'],
})
return self.create(vals) if vals else self.browse()
# ------------------------------------------------------------------
@staticmethod
def _quarter_months(quarter):
start = (quarter - 1) * 3 + 1
return (start, start + 1, start + 2)
@api.model
def ytd_records(self, company, year, up_to_quarter):
"""Записи нарастающим итогом с начала года по конец квартала."""
_, _, last = self._quarter_months(up_to_quarter)
return self.search([
('company_id', '=', company.id),
('year', '=', year),
('month', '>=', 1),
('month', '<=', last),
])

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""Регистр помесячных итогов страховых взносов по сотрудникам (для РСВ).
Аналог rsbu.kudir.summary/rsbu.vat.summary: агрегирует проведённые
расчётные листки (hr.payslip) по (сотрудник × месяц), вычисляя базу и
взносы ОПС/ОМС/ВНиМ через hr.payslip.rsbu_insurance_breakdown (с учётом
регрессии по годовой предельной базе и льготы МСП сверх 1.5 МРОТ).
Служит источником данных для расчёта по страховым взносам (РСВ,
NO_RASCHSV 5.08): раздел 1 (сводные обязательства и подраздел единого
тарифа) и раздел 3 (персонифицированные сведения по месяцам).
"""
from odoo import api, fields, models
class RsbuRsvSummary(models.Model):
_name = 'rsbu.rsv.summary'
_description = 'РСБУ — Итоги страховых взносов по сотрудникам (РСВ)'
_order = 'year desc, month, employee_id'
_check_company_auto = True
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
employee_id = fields.Many2one(
'hr.employee', string='Сотрудник', required=True, index=True)
year = fields.Integer(string='Год', required=True, index=True)
month = fields.Integer(string='Месяц', required=True, index=True)
currency_id = fields.Many2one(
'res.currency', string='Валюта',
compute='_compute_currency_id', store=True)
# Код категории застрахованного (НР/МС) — из сотрудника на момент расчёта.
insured_category = fields.Char(string='Код категории')
payments = fields.Monetary(string='Выплаты (всего)',
currency_field='currency_id')
non_taxable = fields.Monetary(string='Необлагаемые',
currency_field='currency_id')
base = fields.Monetary(string='База', currency_field='currency_id')
base_within_limit = fields.Monetary(
string='База в пределах лимита ОПС', currency_field='currency_id')
base_over_limit = fields.Monetary(
string='База сверх лимита ОПС', currency_field='currency_id')
ops = fields.Monetary(string='Взносы ОПС', currency_field='currency_id')
ops_within = fields.Monetary(string='ОПС в пределах',
currency_field='currency_id')
ops_over = fields.Monetary(string='ОПС сверх',
currency_field='currency_id')
oms = fields.Monetary(string='Взносы ОМС', currency_field='currency_id')
vnim = fields.Monetary(string='Взносы ВНиМ', currency_field='currency_id')
_sql_uniq = models.Constraint(
'unique(company_id, employee_id, year, month)',
'Итог РСВ уникален по сотруднику и месяцу.')
@api.depends('company_id')
def _compute_currency_id(self):
for rec in self:
rec.currency_id = rec.company_id.currency_id
# ------------------------------------------------------------------
@api.model
def recompute_period(self, date_from, date_to, company):
"""Пересобрать помесячные итоги взносов за период из листков.
Идемпотентно: удаляет итоги месяцев периода и заново агрегирует
проведённые расчётные листки (state='done') по сотруднику и месяцу.
"""
Slip = self.env['hr.payslip'].sudo()
# Удаляем прежние записи месяцев, попадающих в период.
self.search([
('company_id', '=', company.id),
('year', '>=', date_from.year),
('year', '<=', date_to.year),
]).filtered(lambda r: date_from <= fields.Date.to_date(
'%04d-%02d-01' % (r.year, r.month)) <= date_to).unlink()
slips = Slip.search([
('company_id', '=', company.id),
('state', '=', 'done'),
('date_from', '>=', date_from),
('date_from', '<=', date_to),
])
acc = {}
for slip in slips:
key = (slip.employee_id.id, slip.date_from.year,
slip.date_from.month)
bd = slip.rsbu_insurance_breakdown()
data = acc.setdefault(key, {
'payments': 0.0, 'non_taxable': 0.0, 'base': 0.0,
'base_within_limit': 0.0, 'base_over_limit': 0.0,
'ops': 0.0, 'ops_within': 0.0, 'ops_over': 0.0,
'oms': 0.0, 'vnim': 0.0,
'category': slip.employee_id.rsbu_insured_category or 'НР',
})
gross = slip._rsbu_insurance_gross()
data['payments'] += gross
data['non_taxable'] += max(0.0, gross - bd['base'])
data['base'] += bd['base']
data['base_within_limit'] += bd['base_within_limit']
data['base_over_limit'] += bd['base_over_limit']
data['ops'] += bd['ops']
data['ops_within'] += bd['ops_within']
data['ops_over'] += bd['ops_over']
data['oms'] += bd['oms']
data['vnim'] += bd['vnim']
vals_list = []
for (emp_id, year, month), d in acc.items():
vals_list.append({
'company_id': company.id,
'employee_id': emp_id,
'year': year,
'month': month,
'insured_category': d['category'],
'payments': round(d['payments'], 2),
'non_taxable': round(d['non_taxable'], 2),
'base': round(d['base'], 2),
'base_within_limit': round(d['base_within_limit'], 2),
'base_over_limit': round(d['base_over_limit'], 2),
'ops': round(d['ops'], 2),
'ops_within': round(d['ops_within'], 2),
'ops_over': round(d['ops_over'], 2),
'oms': round(d['oms'], 2),
'vnim': round(d['vnim'], 2),
})
return self.create(vals_list) if vals_list else self.browse()
# ------------------------------------------------------------------
# Выборки для РСВ
# ------------------------------------------------------------------
@staticmethod
def _quarter_months(quarter):
"""Три календарных месяца квартала (1..4) → (m1, m2, m3)."""
start = (quarter - 1) * 3 + 1
return (start, start + 1, start + 2)
@api.model
def records_for(self, company, year, month_from, month_to):
"""Записи итогов за интервал месяцев года (включительно)."""
return self.search([
('company_id', '=', company.id),
('year', '=', year),
('month', '>=', month_from),
('month', '<=', month_to),
])
@api.model
def ytd_records(self, company, year, up_to_quarter):
"""Записи нарастающим итогом с начала года по конец квартала."""
_, _, last_month = self._quarter_months(up_to_quarter)
return self.records_for(company, year, 1, last_month)