Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
67
rsbu_payroll/README.rst
Normal file
67
rsbu_payroll/README.rst
Normal 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
3
rsbu_payroll/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
|
||||
from .hooks import post_init_setup_rule_accounts
|
||||
61
rsbu_payroll/__manifest__.py
Normal file
61
rsbu_payroll/__manifest__.py
Normal 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'],
|
||||
}
|
||||
35
rsbu_payroll/data/hr_leave_type_data.xml
Normal file
35
rsbu_payroll/data/hr_leave_type_data.xml
Normal 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>
|
||||
20
rsbu_payroll/data/hr_payroll_structure_data.xml
Normal file
20
rsbu_payroll/data/hr_payroll_structure_data.xml
Normal 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>
|
||||
33
rsbu_payroll/data/hr_salary_rule_category_data.xml
Normal file
33
rsbu_payroll/data/hr_salary_rule_category_data.xml
Normal 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>
|
||||
162
rsbu_payroll/data/hr_salary_rule_data.xml
Normal file
162
rsbu_payroll/data/hr_salary_rule_data.xml
Normal 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>
|
||||
24
rsbu_payroll/demo/rsbu_payroll_demo.xml
Normal file
24
rsbu_payroll/demo/rsbu_payroll_demo.xml
Normal 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
78
rsbu_payroll/hooks.py
Normal 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)
|
||||
11
rsbu_payroll/models/__init__.py
Normal file
11
rsbu_payroll/models/__init__.py
Normal 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
|
||||
128
rsbu_payroll/models/hr_employee.py
Normal file
128
rsbu_payroll/models/hr_employee.py
Normal 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
|
||||
18
rsbu_payroll/models/hr_leave_type.py
Normal file
18
rsbu_payroll/models/hr_leave_type.py
Normal 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='Классификация отсутствия для расчёта зарплаты: определяет, как '
|
||||
'дни этого типа учитываются в расчётном листке (отпускные, '
|
||||
'больничный, без оплаты).')
|
||||
656
rsbu_payroll/models/hr_payslip.py
Normal file
656
rsbu_payroll/models/hr_payslip.py
Normal 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
|
||||
160
rsbu_payroll/models/hr_payslip_run.py
Normal file
160
rsbu_payroll/models/hr_payslip_run.py
Normal 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,
|
||||
}
|
||||
29
rsbu_payroll/models/hr_salary_rule.py
Normal file
29
rsbu_payroll/models/hr_salary_rule.py
Normal 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='Проставлять сотрудника расчётного листка субконто в строках '
|
||||
'проводки этого правила.')
|
||||
64
rsbu_payroll/models/res_company.py
Normal file
64
rsbu_payroll/models/res_company.py
Normal 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(сверка справочника кодов тарифа).')
|
||||
30
rsbu_payroll/models/res_config_settings.py
Normal file
30
rsbu_payroll/models/res_config_settings.py
Normal 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)
|
||||
84
rsbu_payroll/models/rsbu_employee_child.py
Normal file
84
rsbu_payroll/models/rsbu_employee_child.py
Normal 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)
|
||||
189
rsbu_payroll/models/rsbu_hr_event.py
Normal file
189
rsbu_payroll/models/rsbu_hr_event.py
Normal 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 ''
|
||||
145
rsbu_payroll/models/rsbu_ndfl_summary.py
Normal file
145
rsbu_payroll/models/rsbu_ndfl_summary.py
Normal 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),
|
||||
])
|
||||
153
rsbu_payroll/models/rsbu_rsv_summary.py
Normal file
153
rsbu_payroll/models/rsbu_rsv_summary.py
Normal 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)
|
||||
9
rsbu_payroll/security/ir.model.access.csv
Normal file
9
rsbu_payroll/security/ir.model.access.csv
Normal 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
|
||||
|
8
rsbu_payroll/security/rsbu_payroll_security.xml
Normal file
8
rsbu_payroll/security/rsbu_payroll_security.xml
Normal 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>
|
||||
BIN
rsbu_payroll/static/description/icon.png
Normal file
BIN
rsbu_payroll/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
1
rsbu_payroll/tests/__init__.py
Normal file
1
rsbu_payroll/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_rsbu_payroll
|
||||
258
rsbu_payroll/tests/test_rsbu_payroll.py
Normal file
258
rsbu_payroll/tests/test_rsbu_payroll.py
Normal 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)
|
||||
68
rsbu_payroll/views/hr_employee_views.xml
Normal file
68
rsbu_payroll/views/hr_employee_views.xml
Normal 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>
|
||||
41
rsbu_payroll/views/hr_payslip_run_views.xml
Normal file
41
rsbu_payroll/views/hr_payslip_run_views.xml
Normal 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>
|
||||
19
rsbu_payroll/views/hr_payslip_views.xml
Normal file
19
rsbu_payroll/views/hr_payslip_views.xml
Normal 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>
|
||||
24
rsbu_payroll/views/hr_salary_rule_views.xml
Normal file
24
rsbu_payroll/views/hr_salary_rule_views.xml
Normal 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>
|
||||
59
rsbu_payroll/views/res_config_settings_views.xml
Normal file
59
rsbu_payroll/views/res_config_settings_views.xml
Normal 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>
|
||||
117
rsbu_payroll/views/rsbu_hr_event_views.xml
Normal file
117
rsbu_payroll/views/rsbu_hr_event_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user