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

67
rsbu_payroll/README.rst Normal file
View File

@@ -0,0 +1,67 @@
====================================
РСБУ — Кадровый и расчётный блок
====================================
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
|badge1| |badge2|
Российский кадрово-расчётный слой поверх OCA ``payroll`` и штатного
``hr_holidays``: расчёт зарплаты, НДФЛ и страховых взносов с формированием
проводок в журнал РСБУ, а также реестр зарплаты (зарплатный проект банка).
**Содержание**
.. contents::
:local:
Возможности
===========
* РФ-структура зарплаты и правила: оклад, премия, отпускные, больничный
(3 дня за счёт работодателя), удержание НДФЛ, страховые взносы (единый
тариф) и взносы на травматизм.
* Счета Дт/Кт РСБУ задаются прямо на правилах расчёта.
* При проведении расчётного листка формируется проводка в журнале РСБУ:
Дт 20/26/44 — Кт 70, Дт 70 — Кт 68.НДФЛ, Дт 20/26/44 — Кт 69.ЕТ/69.ТР;
субконто — сотрудник.
* Реестр зарплаты: единый платёж банка раскладывается на выплаты по
каждому сотруднику (Дт 70 — Кт 51) одной кнопкой.
* Выплаты и уплата взносов признаются в КУДиР по кассовому методу.
Настройка
=========
#. Установите модуль (устанавливается вместе с OCA ``payroll``).
#. В *Настройки → РСБУ — Зарплата* укажите ставку НДФЛ, тариф страховых
взносов и тариф травматизма (значения по умолчанию — заглушки, сверьте
с актуальной нормативкой).
#. При необходимости скорректируйте счета РСБУ на правилах зарплаты.
Использование
=============
#. Назначьте сотруднику контракт со структурой «Зарплата (РФ, РСБУ)».
#. Создайте расчётный листок, рассчитайте и проведите его — сформируется
проводка начисления в журнале РСБУ.
#. Для выплаты соберите расчётные листки в реестр (пакет), укажите банк и
дату, нажмите «Провести выплату» — сформируются проводки Дт 70 — Кт 51
по каждому сотруднику.
Авторы
======
* MK.Lab, RuOdoo
Модуль сопровождается MK.Lab, RuOdoo.
.. image:: https://ruodoo.ru/logo.png
:alt: MK.Lab, RuOdoo
:target: https://ruodoo.ru
Модуль является частью РСБУ-слоя проекта РуОду.

3
rsbu_payroll/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import models
from .hooks import post_init_setup_rule_accounts

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
{
'name': 'РСБУ — Кадровый и расчётный блок',
'version': '19.0.2026.09.17',
'summary': 'Зарплата на OCA payroll + проводки РСБУ, НДФЛ, взносы, реестр выплаты',
'description': """
РСБУ — Кадровый и расчётный блок
================================
Надстройка над OCA `payroll` и штатным `hr_holidays` для российского учёта
зарплаты в РСБУ-слое.
Возможности:
------------
* РФ-структура зарплаты и правила: оклад, премии, больничный (3 дня за счёт
работодателя), удержание НДФЛ, страховые взносы (единый тариф) и взносы
на травматизм.
* Счета Дт/Кт РСБУ задаются прямо на правилах расчёта (план счетов РСБУ).
* При проведении расчётного листка формируется бухгалтерская проводка в
журнале РСБУ: Дт 20/26/44 — Кт 70, Дт 70 — Кт 68.НДФЛ,
Дт 20/26/44 — Кт 69.ЕТ/69.ТР. Субконто — сотрудник.
* Реестр зарплаты (зарплатный проект банка): единый платёж раскладывается
на выплаты по каждому сотруднику (Дт 70 — Кт 51) одной кнопкой.
* Выплаты и уплата взносов признаются в КУДиР по кассовому методу.
Ставки НДФЛ и страховых взносов задаются в настройках компании.
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Accounting/Localizations',
'depends': [
'payroll',
'hr_holidays',
'rsbu_connector',
'rsbu_kudir',
'rsbu_journal',
'rsbu_chart_of_accounts',
],
'data': [
'security/rsbu_payroll_security.xml',
'security/ir.model.access.csv',
'data/hr_salary_rule_category_data.xml',
'data/hr_salary_rule_data.xml',
'data/hr_payroll_structure_data.xml',
'data/hr_leave_type_data.xml',
'views/hr_employee_views.xml',
'views/hr_salary_rule_views.xml',
'views/hr_payslip_views.xml',
'views/hr_payslip_run_views.xml',
'views/res_config_settings_views.xml',
'views/rsbu_hr_event_views.xml',
],
'demo': [
'demo/rsbu_payroll_demo.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
'post_init_hook': 'post_init_setup_rule_accounts',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Ежегодный оплачиваемый отпуск -->
<record id="rsbu_leave_annual" model="hr.leave.type">
<field name="name">Ежегодный оплачиваемый отпуск</field>
<field name="requires_allocation" eval="False"/>
<field name="request_unit">day</field>
<field name="leave_validation_type">hr</field>
<field name="time_type">leave</field>
<field name="rsbu_leave_kind">vacation</field>
</record>
<!-- Больничный лист -->
<record id="rsbu_leave_sick" model="hr.leave.type">
<field name="name">Больничный лист</field>
<field name="requires_allocation" eval="False"/>
<field name="request_unit">day</field>
<field name="leave_validation_type">hr</field>
<field name="time_type">leave</field>
<field name="rsbu_leave_kind">sick</field>
</record>
<!-- Отпуск без сохранения заработной платы -->
<record id="rsbu_leave_unpaid" model="hr.leave.type">
<field name="name">Отпуск без сохранения зарплаты</field>
<field name="requires_allocation" eval="False"/>
<field name="request_unit">day</field>
<field name="leave_validation_type">hr</field>
<field name="time_type">leave</field>
<field name="unpaid" eval="True"/>
<field name="rsbu_leave_kind">unpaid</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="rsbu_structure_ru" model="hr.payroll.structure">
<field name="name">Зарплата (РФ, РСБУ)</field>
<field name="code">RU</field>
<field name="rule_ids" eval="[(6, 0, [
ref('rsbu_rule_basic'),
ref('rsbu_rule_premium'),
ref('rsbu_rule_vacation'),
ref('rsbu_rule_sick3'),
ref('rsbu_rule_gross'),
ref('rsbu_rule_ndfl'),
ref('rsbu_rule_insurance'),
ref('rsbu_rule_injury'),
ref('rsbu_rule_net'),
])]"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="rsbu_cat_basic" model="hr.salary.rule.category">
<field name="name">Оклад</field>
<field name="code">RSBU_BASIC</field>
</record>
<record id="rsbu_cat_alw" model="hr.salary.rule.category">
<field name="name">Надбавки и премии</field>
<field name="code">RSBU_ALW</field>
</record>
<record id="rsbu_cat_gross" model="hr.salary.rule.category">
<field name="name">Начислено (итог)</field>
<field name="code">RSBU_GROSS</field>
</record>
<record id="rsbu_cat_ndfl" model="hr.salary.rule.category">
<field name="name">НДФЛ</field>
<field name="code">RSBU_NDFL</field>
</record>
<record id="rsbu_cat_insurance" model="hr.salary.rule.category">
<field name="name">Страховые взносы (единый тариф)</field>
<field name="code">RSBU_INSURANCE</field>
</record>
<record id="rsbu_cat_injury" model="hr.salary.rule.category">
<field name="name">Взносы на травматизм</field>
<field name="code">RSBU_INJURY</field>
</record>
<record id="rsbu_cat_net" model="hr.salary.rule.category">
<field name="name">К выплате</field>
<field name="code">RSBU_NET</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Правила расчёта — конфигурация модуля, должны обновляться при -u
(без noupdate). Счета РСБУ на правилах проставляет post_init hook. -->
<data>
<!-- Оклад. Базовый вариант — полный оклад контракта.
Пропорция по отработанным дням (worked_days.WORK100) может быть
добавлена при уточнении алгоритма; сейчас оклад берётся полностью
или из входа BASIC, если задан вручную. -->
<record id="rsbu_rule_basic" model="hr.salary.rule">
<field name="name">Оклад</field>
<field name="code">BASIC</field>
<field name="sequence" eval="10"/>
<field name="category_id" ref="rsbu_cat_basic"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# Оклад пропорционально отработанным дням (норма и отработанные берутся из
# календаря и hr.leave автоматически: отпуск/больничный/за свой счёт
# уменьшают отработанные дни). Ручной ввод не требуется.
norm = payslip.rsbu_norm_days or 0.0
worked = payslip.rsbu_worked_days or 0.0
if norm:
result = contract.wage * worked / norm
else:
result = contract.wage
</field>
</record>
<!-- Премия: сумма из входа PREMIUM (hr.payslip.input), иначе 0. -->
<record id="rsbu_rule_premium" model="hr.salary.rule">
<field name="name">Премия</field>
<field name="code">PREMIUM</field>
<field name="sequence" eval="20"/>
<field name="category_id" ref="rsbu_cat_alw"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = inputs.PREMIUM.amount if 'PREMIUM' in inputs.dict else 0.0
</field>
</record>
<!-- Отпускные = средний дневной заработок × календарные дни отпуска.
Дни отпуска берутся из hr.leave (тип «Оплачиваемый отпуск»)
автоматически. -->
<record id="rsbu_rule_vacation" model="hr.salary.rule">
<field name="name">Отпускные</field>
<field name="code">VACATION</field>
<field name="sequence" eval="30"/>
<field name="category_id" ref="rsbu_cat_alw"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
avg = employee.rsbu_avg_daily_wage or 0.0
result = avg * (payslip.rsbu_vacation_cal_days or 0.0)
</field>
</record>
<!-- Больничный за счёт работодателя (первые 3 дня).
Пособие = средний дневной заработок × min(3, дни болезни) ×
процент по страховому стажу (≥8 лет 100%, 5-8 лет 80%, <5 лет 60%).
Дни болезни подаются входом SICK_DAYS. Первые 3 дня платит
работодатель, остальные — СФР (в наш учёт не попадают).
Облагается НДФЛ, но НЕ облагается страховыми взносами. -->
<record id="rsbu_rule_sick3" model="hr.salary.rule">
<field name="name">Больничный (3 дня за счёт работодателя)</field>
<field name="code">SICK3</field>
<field name="sequence" eval="40"/>
<field name="category_id" ref="rsbu_cat_alw"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# Календарные дни больничного берутся из hr.leave (тип «Больничный»)
# автоматически. Работодатель оплачивает первые 3 календарных дня.
sick_days = payslip.rsbu_sick_cal_days or 0.0
employer_days = min(3.0, sick_days)
avg = employee.rsbu_avg_daily_wage or 0.0
rate = employee.rsbu_sick_rate()
result = avg * employer_days * rate
</field>
</record>
<!-- Начислено (итог, облагаемая НДФЛ база). -->
<record id="rsbu_rule_gross" model="hr.salary.rule">
<field name="name">Начислено</field>
<field name="code">GROSS</field>
<field name="sequence" eval="100"/>
<field name="category_id" ref="rsbu_cat_gross"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = categories.RSBU_BASIC + categories.RSBU_ALW
</field>
</record>
<!-- НДФЛ: прогрессивная шкала 13–22% нарастающим итогом с начала
года + стандартные вычеты (ст.224/225/218 НК). Расчёт вынесен
в rsbu_compute_ndfl (нужен доступ к листкам года и вычетам).
Результат отрицательный (удержание). -->
<record id="rsbu_rule_ndfl" model="hr.salary.rule">
<field name="name">НДФЛ</field>
<field name="code">NDFL</field>
<field name="sequence" eval="110"/>
<field name="category_id" ref="rsbu_cat_ndfl"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = - payslip.rsbu_compute_ndfl(current_gross=categories.RSBU_GROSS)
</field>
</record>
<!-- Страховые взносы (единый тариф): база = начислено минус больничный
(больничный взносами не облагается). Затраты работодателя.
Переменная SICK3 в localdict = сумма (total) правила больничного. -->
<record id="rsbu_rule_insurance" model="hr.salary.rule">
<field name="name">Страховые взносы (единый тариф)</field>
<field name="code">INSURANCE</field>
<field name="sequence" eval="120"/>
<field name="category_id" ref="rsbu_cat_insurance"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# Сумма = разбивка ОПС+ОМС+ВНиМ (с учётом льготы МСП сверх 1.5 МРОТ).
# База берётся из localdict (RSBU_GROSS − SICK3), т.к. строки листка на
# момент расчёта ещё не сохранены. Разбивка по видам для РСВ доступна
# через payslip.rsbu_insurance_breakdown(base).
result = payslip.rsbu_insurance_breakdown(
base=max(0.0, categories.RSBU_GROSS - SICK3))['total']
</field>
</record>
<!-- Взносы на травматизм: та же база, тариф из настроек. -->
<record id="rsbu_rule_injury" model="hr.salary.rule">
<field name="name">Взносы на травматизм</field>
<field name="code">INJURY</field>
<field name="sequence" eval="130"/>
<field name="category_id" ref="rsbu_cat_injury"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
rate = payslip.company_id.rsbu_injury_rate or 0.0
base = categories.RSBU_GROSS - SICK3
result = base * rate / 100.0
</field>
</record>
<!-- К выплате: начислено минус НДФЛ (НДФЛ отрицательный). -->
<record id="rsbu_rule_net" model="hr.salary.rule">
<field name="name">К выплате</field>
<field name="code">NET</field>
<field name="sequence" eval="200"/>
<field name="category_id" ref="rsbu_cat_net"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = categories.RSBU_GROSS + categories.RSBU_NDFL
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Демо-сотрудник с контрактом (hr.version) и РФ-структурой зарплаты -->
<record id="rsbu_demo_employee" model="hr.employee">
<field name="company_id" ref="base.main_company"/>
<field name="name">Петров Пётр Петрович</field>
<field name="date_version" eval="time.strftime('%Y-%m')+'-01'"/>
<field name="contract_date_start" eval="time.strftime('%Y-%m')+'-01'"/>
<field name="contract_date_end" eval="time.strftime('%Y')+'-12-31'"/>
<field name="struct_id" ref="rsbu_payroll.rsbu_structure_ru"/>
<field name="wage" eval="60000.0"/>
</record>
<!-- Пример расчётного листка за текущий месяц -->
<record id="rsbu_demo_payslip" model="hr.payslip">
<field name="name">Расчётный листок (демо)</field>
<field name="employee_id" ref="rsbu_demo_employee"/>
<field name="struct_id" ref="rsbu_payroll.rsbu_structure_ru"/>
<field name="date_from" eval="time.strftime('%Y-%m')+'-01'"/>
<field name="date_to" eval="(DateTime.today() + relativedelta(day=31)).strftime('%Y-%m-%d')"/>
</record>
</data>
</odoo>

78
rsbu_payroll/hooks.py Normal file
View File

@@ -0,0 +1,78 @@
import logging
_logger = logging.getLogger(__name__)
# Схема проводок кадрового цикла: код правила → (счёт Дт, счёт Кт) в кодах
# плана счетов РСБУ 94н. Счёт затрат по умолчанию — 26 (общехозяйственные
# расходы), типично для УСН-компаний сферы услуг; переопределяется вручную.
RULE_ACCOUNTS = {
'BASIC': ('26', '70'), # начисление оклада
'PREMIUM': ('26', '70'), # премия
'VACATION': ('26', '70'), # отпускные
'SICK3': ('26', '70'), # больничный за счёт работодателя
'NDFL': ('70', '68.НДФЛ'), # удержание НДФЛ
'INSURANCE': ('26', '69.ЕТ'), # страховые взносы (единый тариф)
'INJURY': ('26', '69.ТР'), # взносы на травматизм
# GROSS / NET — промежуточные итоги, счетов не имеют.
}
def _find_account(env, company, code):
return env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', company.id),
], limit=1)
def _assign_accounts_for_company(env, company):
"""Проставить счета РСБУ на правилах зарплаты для одной компании."""
Rule = env['hr.salary.rule']
for code, (debit_code, credit_code) in RULE_ACCOUNTS.items():
rule = env.ref(
'rsbu_payroll.rsbu_rule_%s' % code.lower(),
raise_if_not_found=False)
if not rule:
continue
debit = _find_account(env, company, debit_code)
credit = _find_account(env, company, credit_code)
if not (debit and credit):
_logger.info(
'rsbu_payroll: для компании %s не найдены счета %s/%s '
'(план счетов РСБУ не загружен?) — правило %s не настроено.',
company.display_name, debit_code, credit_code, code)
continue
# Счета company_dependent — пишем в контексте компании.
rule.with_company(company).write({
'rsbu_account_debit': debit.id,
'rsbu_account_credit': credit.id,
})
def _ensure_leave_kinds(env):
"""Проставить вид отсутствия (rsbu_leave_kind) на типах hr.leave.type.
Типы отсутствий создаются с noupdate=1 (пользователь может их
настраивать), поэтому классификатор проставляем программно для
надёжности и на существующих базах.
"""
mapping = {
'rsbu_payroll.rsbu_leave_annual': 'vacation',
'rsbu_payroll.rsbu_leave_sick': 'sick',
'rsbu_payroll.rsbu_leave_unpaid': 'unpaid',
}
for xmlid, kind in mapping.items():
rec = env.ref(xmlid, raise_if_not_found=False)
if rec and rec.rsbu_leave_kind != kind:
rec.rsbu_leave_kind = kind
def post_init_setup_rule_accounts(env):
"""Назначить счета РСБУ на правилах зарплаты по всем компаниям.
Счета `rsbu.account` — company-dependent и без стабильных xmlid, поэтому
задаём их программно поиском по коду для каждой компании с загруженным
планом счетов РСБУ. Идемпотентно (повторный вызов перезаписывает).
"""
for company in env['res.company'].search([]):
_assign_accounts_for_company(env, company)
_ensure_leave_kinds(env)

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)

View File

@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rsbu_employee_child_user,rsbu.employee.child user,model_rsbu_employee_child,hr.group_hr_user,1,1,1,1
access_rsbu_employee_child_mgr,rsbu.employee.child manager,model_rsbu_employee_child,hr.group_hr_manager,1,1,1,1
access_rsbu_rsv_summary_user,rsbu.rsv.summary user,model_rsbu_rsv_summary,hr.group_hr_user,1,1,1,1
access_rsbu_rsv_summary_mgr,rsbu.rsv.summary manager,model_rsbu_rsv_summary,hr.group_hr_manager,1,1,1,1
access_rsbu_ndfl_summary_user,rsbu.ndfl.summary user,model_rsbu_ndfl_summary,hr.group_hr_user,1,1,1,1
access_rsbu_ndfl_summary_mgr,rsbu.ndfl.summary manager,model_rsbu_ndfl_summary,hr.group_hr_manager,1,1,1,1
access_rsbu_hr_event_user,rsbu.hr.event user,model_rsbu_hr_event,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_hr_event_mgr,rsbu.hr.event manager,model_rsbu_hr_event,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_rsbu_employee_child_user rsbu.employee.child user model_rsbu_employee_child hr.group_hr_user 1 1 1 1
3 access_rsbu_employee_child_mgr rsbu.employee.child manager model_rsbu_employee_child hr.group_hr_manager 1 1 1 1
4 access_rsbu_rsv_summary_user rsbu.rsv.summary user model_rsbu_rsv_summary hr.group_hr_user 1 1 1 1
5 access_rsbu_rsv_summary_mgr rsbu.rsv.summary manager model_rsbu_rsv_summary hr.group_hr_manager 1 1 1 1
6 access_rsbu_ndfl_summary_user rsbu.ndfl.summary user model_rsbu_ndfl_summary hr.group_hr_user 1 1 1 1
7 access_rsbu_ndfl_summary_mgr rsbu.ndfl.summary manager model_rsbu_ndfl_summary hr.group_hr_manager 1 1 1 1
8 access_rsbu_hr_event_user rsbu.hr.event user model_rsbu_hr_event rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
9 access_rsbu_hr_event_mgr rsbu.hr.event manager model_rsbu_hr_event rsbu_journal.group_rsbu_journal_manager 1 1 1 1

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="group_rsbu_payroll_user" model="res.groups">
<field name="name">РСБУ: Кадры и расчёт</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
from . import test_rsbu_payroll

View File

@@ -0,0 +1,258 @@
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)

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rsbu_hr_employee_view_form" model="ir.ui.view">
<field name="name">rsbu.hr.employee.form</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='hr_settings']" position="after">
<page name="rsbu_payroll_page" string="РСБУ — Расчёт пособий">
<group string="Персональные данные для отчётности">
<group>
<field name="rsbu_snils"/>
<field name="rsbu_inn"/>
<field name="rsbu_insured_category"/>
<field name="rsbu_taxpayer_status"/>
</group>
<group string="Раздел 3 РСВ (перс. сведения)">
<field name="rsbu_sex"/>
<field name="rsbu_citizenship_code"/>
<field name="rsbu_id_doc_code"/>
<field name="rsbu_id_doc_number"/>
</group>
</group>
<div class="alert alert-info" role="alert">
СНИЛС, ИНН, код категории застрахованного и статус
налогоплательщика используются в персонифицированных
сведениях, РСВ, ЕФС-1 и 6-НДФЛ. Дата рождения и
гражданство берутся из вкладки «Личная информация».
</div>
<group string="Стандартные вычеты по НДФЛ">
<group>
<field name="rsbu_personal_deduction_code"/>
<field name="rsbu_gto_deduction"/>
</group>
</group>
<field name="rsbu_child_ids">
<list editable="bottom">
<field name="name"/>
<field name="birthdate"/>
<field name="order"/>
<field name="is_disabled"/>
<field name="is_student"/>
<field name="is_guardian"/>
<field name="single_parent"/>
</list>
</field>
<div class="alert alert-info" role="alert">
Детские вычеты (1400 / 2800 / 6000 ₽ по очерёдности,
+12 000 ₽ на ребёнка-инвалида) применяются, пока доход
нарастающим итогом не превысит 450 000 ₽. Прогрессивная
шкала НДФЛ 13–22% считается автоматически при расчёте.
</div>
<group string="Расчёт пособий">
<field name="rsbu_insurance_years"/>
<field name="rsbu_avg_daily_wage"/>
<field name="rsbu_currency_id" invisible="1"/>
</group>
<div class="alert alert-info" role="alert">
Страховой стаж определяет процент оплаты больничного
(≥8 лет — 100%, 5–8 лет — 80%, менее 5 лет — 60%).
Средний дневной заработок используется для расчёта
больничного (позже — калькулятор за 2 года).
</div>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rsbu_hr_payslip_run_view_form" model="ir.ui.view">
<field name="name">rsbu.hr.payslip.run.form</field>
<field name="model">hr.payslip.run</field>
<field name="inherit_id" ref="payroll.hr_payslip_run_view_form"/>
<field name="arch" type="xml">
<!-- Кнопки реестра выплаты в шапке -->
<xpath expr="//header" position="inside">
<button name="action_rsbu_pay" type="object"
string="Провести выплату" class="btn-primary"
invisible="payout_state == 'paid'"/>
<button name="action_rsbu_pay_cancel" type="object"
string="Отменить выплату"
invisible="payout_state != 'paid'"/>
<field name="payout_state" widget="statusbar"/>
</xpath>
<!-- Реквизиты зарплатного проекта и итоги -->
<xpath expr="//sheet" position="before">
<div class="oe_button_box" name="rsbu_run_button_box">
<button name="action_view_rsbu_payout_move" type="object"
class="oe_stat_button" icon="fa-book"
invisible="not rsbu_payout_move_id">
<div class="o_stat_info">
<span class="o_stat_text">Проводка выплаты</span>
</div>
</button>
</div>
</xpath>
<xpath expr="//group" position="after">
<group string="Зарплатный проект (реестр)">
<field name="payment_bank_partner_id"/>
<field name="payment_date"/>
<field name="bank_fee_amount"/>
<field name="total_net"/>
<field name="currency_id" invisible="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rsbu_hr_payslip_view_form" model="ir.ui.view">
<field name="name">rsbu.hr.payslip.form</field>
<field name="model">hr.payslip</field>
<field name="inherit_id" ref="payroll.hr_payslip_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_rsbu_move" type="object"
class="oe_stat_button" icon="fa-book"
invisible="not rsbu_move_id">
<div class="o_stat_info">
<span class="o_stat_text">РСБУ-проводка</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rsbu_hr_salary_rule_view_form" model="ir.ui.view">
<field name="name">rsbu.hr.salary.rule.form</field>
<field name="model">hr.salary.rule</field>
<field name="inherit_id" ref="payroll.hr_salary_rule_view_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page name="rsbu_accounts" string="Счета РСБУ">
<group>
<field name="rsbu_account_debit"/>
<field name="rsbu_account_credit"/>
<field name="rsbu_subconto_employee"/>
</group>
<div class="alert alert-info" role="alert">
Счета плана счетов РСБУ, по которым формируется проводка
при проведении расчётного листка. Правила-итоги
(Начислено, К выплате) счетов не имеют.
</div>
</page>
</notebook>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rsbu_payroll_res_config_settings_view_form" model="ir.ui.view">
<field name="name">rsbu.payroll.res.config.settings.form</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="РСБУ — Зарплата" string="РСБУ — Зарплата"
name="rsbu_payroll"
img="/rsbu_payroll/static/description/icon.png">
<block title="Страховые взносы (единый тариф)"
help="Разбивка единого тарифа на виды для РСВ. Сумма ОПС+ОМС+ВНиМ = единый тариф. Сверьте с нормативкой 2026.">
<setting string="Тариф ОПС, %">
<field name="rsbu_rate_ops"/>
</setting>
<setting string="Тариф ОМС, %">
<field name="rsbu_rate_oms"/>
</setting>
<setting string="Тариф ВНиМ, %">
<field name="rsbu_rate_vnim"/>
</setting>
<setting string="Тариф взносов на травматизм, %">
<field name="rsbu_injury_rate"/>
</setting>
</block>
<block title="Льготный тариф МСП"
help="Для субъектов МСП: выплаты сверх 1.5 МРОТ по льготному тарифу.">
<setting string="Применять льготный тариф МСП">
<field name="rsbu_msp_enabled"/>
</setting>
<setting string="Льготный тариф МСП, %"
invisible="not rsbu_msp_enabled">
<field name="rsbu_msp_rate"/>
</setting>
<setting string="МРОТ"
invisible="not rsbu_msp_enabled">
<field name="rsbu_min_wage"/>
<field name="company_currency_id" invisible="1"/>
</setting>
<setting string="Предельная база взносов (год)">
<field name="rsbu_insurance_base_limit"/>
</setting>
<setting string="Код тарифа плательщика (РСВ)"
help="ТарифПлат в РСВ: 01 — основной, 20 — МСП">
<field name="rsbu_payer_tariff_code"/>
</setting>
</block>
<block title="НДФЛ"
help="НДФЛ считается по прогрессивной шкале 13–22% нарастающим итогом с учётом вычетов (ст.224/225/218 НК). Ставка ниже — справочная.">
<setting string="Базовая ставка НДФЛ, % (справочно)">
<field name="rsbu_ndfl_rate"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_hr_event_list" model="ir.ui.view">
<field name="name">rsbu.hr.event.list</field>
<field name="model">rsbu.hr.event</field>
<field name="arch" type="xml">
<list string="Кадровые мероприятия">
<field name="event_date"/>
<field name="employee_id"/>
<field name="kind"/>
<field name="job_title"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_hr_event_form" model="ir.ui.view">
<field name="name">rsbu.hr.event.form</field>
<field name="model">rsbu.hr.event</field>
<field name="arch" type="xml">
<form string="Кадровое мероприятие">
<header>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Мероприятие">
<field name="employee_id"/>
<field name="event_date"/>
<field name="kind"/>
<field name="event_uuid" readonly="1"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
<group string="Сведения о работе">
<field name="job_title"/>
<field name="employment_type"/>
<field name="work_kind"/>
<field name="department_name"/>
<field name="func_code"/>
<field name="okz_code"
placeholder="напр. 2512.1"/>
</group>
</group>
<group string="Документ-основание">
<group>
<field name="basis_name"/>
<field name="basis_date"/>
</group>
<group>
<field name="basis_series"/>
<field name="basis_number"/>
</group>
</group>
<group string="Основание увольнения"
invisible="kind != '5'">
<group>
<field name="dismissal_reason_code"
placeholder="напр. п3ч1с77тк"/>
<field name="dismissal_document"/>
<field name="dismissal_reason"/>
</group>
<group>
<field name="dismissal_article"/>
<field name="dismissal_part"/>
<field name="dismissal_point"/>
<field name="dismissal_subpoint"/>
<field name="dismissal_paragraph"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_rsbu_hr_event_search" model="ir.ui.view">
<field name="name">rsbu.hr.event.search</field>
<field name="model">rsbu.hr.event</field>
<field name="arch" type="xml">
<search string="Кадровые мероприятия">
<field name="employee_id"/>
<field name="kind"/>
<filter name="draft" string="Черновики"
domain="[('state', '=', 'draft')]"/>
<filter name="reported" string="Поданные"
domain="[('state', '=', 'reported')]"/>
<separator/>
<filter name="group_employee" string="Сотрудник"
context="{'group_by': 'employee_id'}"/>
<filter name="group_kind" string="Вид мероприятия"
context="{'group_by': 'kind'}"/>
</search>
</field>
</record>
<record id="action_rsbu_hr_event" model="ir.actions.act_window">
<field name="name">Кадровые мероприятия</field>
<field name="res_model">rsbu.hr.event</field>
<field name="view_mode">list,form</field>
</record>
<!-- Раздел «Кадровый учёт» в приложении Payroll (OCA). Здесь же
будущий «Воинский учёт». Наследуемся от корневого меню OCA-модуля
payroll; сам код остаётся в rsbu_payroll. -->
<menuitem id="menu_rsbu_hr" name="Кадровый учёт"
parent="payroll.payroll_menu_root" sequence="50"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
<menuitem id="menu_rsbu_hr_event"
name="Кадровые мероприятия"
parent="menu_rsbu_hr" action="action_rsbu_hr_event"
sequence="10"/>
</odoo>