Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user