Files
public/rsbu_payroll/models/hr_payslip.py

657 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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