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