Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,656 @@
from odoo import api, fields, models, _
class HrPayslip(models.Model):
"""Расчётный листок + генерация проводки РСБУ при проведении.
Наследует `rsbu.posting.mixin` (Этап 3): по событию проведения
расчётного листка (`action_payslip_done`) идемпотентно создаётся и
проводится операция в журнале РСБУ; при отмене/возврате в черновик —
удаляется. Строки Дт/Кт собираются из строк расчёта по счетам,
заданным на правилах (см. `hr.salary.rule`).
"""
_name = 'hr.payslip'
_inherit = ['hr.payslip', 'rsbu.posting.mixin']
# Данные из hr.leave/календаря для автоматического расчёта (без ручных input).
rsbu_norm_days = fields.Float(
string='Норма рабочих дней', compute='_compute_rsbu_leave_data')
rsbu_worked_days = fields.Float(
string='Отработано дней', compute='_compute_rsbu_leave_data')
rsbu_vacation_cal_days = fields.Float(
string='Кал. дней отпуска', compute='_compute_rsbu_leave_data')
rsbu_sick_cal_days = fields.Float(
string='Кал. дней больничного', compute='_compute_rsbu_leave_data')
rsbu_unpaid_work_days = fields.Float(
string='Раб. дней без содержания', compute='_compute_rsbu_leave_data')
# Дата удержания НДФЛ = дата фактической выплаты дохода. Определяет
# срок перечисления налога для 6-НДФЛ (раздел 1, 6 сроков квартала).
# Заполняется автоматически при проведении из даты выплаты реестра
# (payslip_run_id.payment_date) с фолбэком на последний день периода;
# может быть скорректирована вручную.
rsbu_withholding_date = fields.Date(
string='Дата удержания НДФЛ',
help='Дата фактической выплаты дохода (удержания НДФЛ). Определяет '
'срок перечисления для 6-НДФЛ. По умолчанию — дата выплаты '
'реестра, иначе последний день расчётного периода.')
def _rsbu_default_withholding_date(self):
"""Дата удержания по умолчанию: дата выплаты реестра или конец периода."""
self.ensure_one()
run = self.payslip_run_id
if run and run.payment_date:
return run.payment_date
return self.date_to
@api.depends('employee_id', 'contract_id', 'date_from', 'date_to')
def _compute_rsbu_leave_data(self):
"""Собрать данные об отсутствиях из hr.leave и календаря за период.
Норма рабочих дней по календарю; отработанные = норма минус рабочие
дни всех отсутствий; календарные дни отпуска и больничного — для
расчёта отпускных и пособия. Всё берётся автоматически, без input.
"""
for slip in self:
slip.rsbu_norm_days = 0.0
slip.rsbu_worked_days = 0.0
slip.rsbu_vacation_cal_days = 0.0
slip.rsbu_sick_cal_days = 0.0
slip.rsbu_unpaid_work_days = 0.0
contract = slip.contract_id or slip._get_employee_contracts()[:1]
if not (slip.employee_id and slip.date_from and slip.date_to and contract):
continue
calendar = contract.resource_calendar_id
if not calendar:
continue
# Норма рабочих дней по календарю за период (без вычета отсутствий).
# _get_work_days_data_batch требует datetime (с временем), не date.
from datetime import datetime, time
dt_from = datetime.combine(slip.date_from, time.min)
dt_to = datetime.combine(slip.date_to, time.max)
data = slip.employee_id._get_work_days_data_batch(
dt_from, dt_to, calendar=calendar, compute_leaves=False)
norm = data.get(slip.employee_id.id, {}).get('days', 0.0)
slip.rsbu_norm_days = norm
# Отсутствия сотрудника за период (одобренные), по видам.
leaves = self.env['hr.leave'].search([
('employee_id', '=', slip.employee_id.id),
('state', '=', 'validate'),
('request_date_from', '<=', slip.date_to),
('request_date_to', '>=', slip.date_from),
])
work_leave_days = 0.0 # рабочие дни всех отсутствий
for lv in leaves:
kind = lv.holiday_status_id.rsbu_leave_kind or 'other'
# рабочие дни отсутствия в пределах периода
d_from = max(lv.request_date_from, slip.date_from)
d_to = min(lv.request_date_to, slip.date_to)
cal_days = (d_to - d_from).days + 1
wdays = lv.number_of_days # рабочие дни отсутствия (hr.leave)
work_leave_days += wdays
if kind == 'vacation':
slip.rsbu_vacation_cal_days += cal_days
elif kind == 'sick':
slip.rsbu_sick_cal_days += cal_days
elif kind == 'unpaid':
slip.rsbu_unpaid_work_days += wdays
slip.rsbu_worked_days = max(0.0, norm - work_leave_days)
def action_payslip_done(self):
res = super().action_payslip_done()
for slip in self.filtered(lambda s: s.state == 'done'):
# Проставить дату удержания НДФЛ, если не задана вручную.
if not slip.rsbu_withholding_date:
slip.rsbu_withholding_date = slip._rsbu_default_withholding_date()
slip._rsbu_generate()
return res
def action_payslip_cancel(self):
# Снять РСБУ-проводку до отмены расчётного листка.
self._rsbu_remove()
return super().action_payslip_cancel()
def action_payslip_draft(self):
self._rsbu_remove()
return super().action_payslip_draft()
# ------------------------------------------------------------------
# НДФЛ: прогрессивная шкала 13–22% нарастающим итогом + вычеты
# (ст. 224, 225, 218 НК РФ, 2026). Вызывается правилом NDFL.
# ------------------------------------------------------------------
# Пороги основной базы (₽, нарастающим итогом) и ставки сверх порога.
# 13% до 2.4М, 15% до 5М, 18% до 20М, 20% до 50М, 22% свыше (ст.224 НК).
NDFL_BRACKETS = [
(2_400_000.0, 0.13),
(5_000_000.0, 0.15),
(20_000_000.0, 0.18),
(50_000_000.0, 0.20),
(None, 0.22),
]
NDFL_NONRESIDENT_RATE = 0.30 # обычный нерезидент (п.3 ст.224 НК)
NDFL_CHILD_INCOME_LIMIT = 450_000.0 # лимит дохода для детских вычетов
NDFL_PERSONAL_DEDUCTION = {'104': 500.0, '105': 3000.0}
NDFL_GTO_DEDUCTION = 18_000.0
@api.model
def _ndfl_progressive_tax(self, taxable_base):
"""НДФЛ по прогрессивной шкале от налоговой базы (нарастающим итогом).
Повышенная ставка применяется к сумме превышения порога, не ко
всему доходу (п.1 ст.225 НК). Возвращает исчисленный налог по слоям.
"""
if taxable_base <= 0:
return 0.0
tax = 0.0
lower = 0.0
for upper, rate in self.NDFL_BRACKETS:
if upper is None:
tax += (taxable_base - lower) * rate
break
if taxable_base > upper:
tax += (upper - lower) * rate
lower = upper
else:
tax += (taxable_base - lower) * rate
break
return round(tax, 2)
def _ndfl_prior_gross(self):
"""Облагаемый доход GROSS проведённых листков сотрудника за год
(до текущего листка, нарастающим итогом основной базы)."""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
return sum(
l.total for s in prior
for l in s.line_ids.filtered(lambda x: x.code == 'GROSS'))
def _ndfl_payslip_months(self, include_current=True):
"""Число расчётных месяцев года по фактическим листкам сотрудника.
Стандартные вычеты положены за месяцы налогового периода, в которых
были трудовые отношения (есть расчётный листок), а не с 1 января
формально. Считаем уникальные месяцы (год-месяц) проведённых листков
года до текущего плюс текущий (если include_current).
"""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
months = {(s.date_from.year, s.date_from.month) for s in prior}
if include_current:
months.add((self.date_from.year, self.date_from.month))
return len(months)
def _ndfl_ytd_deductions(self, ytd_total):
"""Стандартные вычеты сотрудника нарастающим итогом с начала года.
Детские вычеты — помесячно, пока доход нарастающим итогом не превысил
450 000 ₽ (в месяц превышения не даётся); личный вычет — помесячно
без лимита дохода; ГТО — разово за год. `ytd_total` — доход основной
базы нарастающим итогом включая текущий месяц.
"""
self.ensure_one()
employee = self.employee_id
if not employee.rsbu_allows_deductions():
return 0.0
# Число расчётных месяцев по фактическим листкам (включая текущий).
months = self._ndfl_payslip_months(include_current=True)
# Детский вычет за месяц (сумма по всем детям).
month_child = sum(
child._deduction_amount(self.date_from)
for child in employee.rsbu_child_ids)
# Число месяцев года, где доход ещё не превысил лимит 450 000 ₽.
# Точная помесячная проверка требует дохода каждого месяца; для
# базового сценария (равномерная зарплата) оцениваем по среднему.
eligible_months = months
if ytd_total > self.NDFL_CHILD_INCOME_LIMIT and months > 0:
avg_per_month = ytd_total / months
if avg_per_month > 0:
eligible_months = min(months, int(
self.NDFL_CHILD_INCOME_LIMIT // avg_per_month))
child_deduction = month_child * max(0, eligible_months)
# Личный вычет (500/3000 ₽) — помесячно без лимита.
personal = self.NDFL_PERSONAL_DEDUCTION.get(
employee.rsbu_personal_deduction_code, 0.0) * months
# Вычет ГТО — разово за год.
gto = self.NDFL_GTO_DEDUCTION if employee.rsbu_gto_deduction else 0.0
return round(child_deduction + personal + gto, 2)
def _ndfl_tax_withheld_before(self):
"""НДФЛ, удержанный по предыдущим листкам года (нарастающий итог)."""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
# NDFL в строках отрицательный (удержание) — берём модуль.
return round(abs(sum(
l.total for s in prior
for l in s.line_ids.filtered(lambda x: x.code == 'NDFL'))), 2)
def rsbu_compute_ndfl(self, current_gross=None):
"""НДФЛ текущего листка (₽, положительное число к удержанию).
Алгоритм (нарастающим итогом с начала года, ст.224/225/218 НК):
1. Доход основной базы YTD = GROSS проведённых листков года +
текущий GROSS;
2. Обычный нерезидент — 30% на текущий GROSS без вычетов и без
нарастающего итога; иначе прогрессивная шкала 13–22% от
(доход YTD − вычеты YTD);
3. Налог месяца = налог с базы YTD − налог, удержанный ранее.
`current_gross` передаётся правилом из localdict (categories.
RSBU_GROSS); если не задан — берётся из строк листка (после расчёта).
Возвращает сумму к удержанию за текущий листок.
"""
self.ensure_one()
employee = self.employee_id
if current_gross is None:
current_gross = sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total'))
# Обычный нерезидент: плоские 30% на текущий доход, без вычетов.
if not employee.rsbu_is_progressive_ndfl():
return round(current_gross * self.NDFL_NONRESIDENT_RATE, 2)
prior_gross = self._ndfl_prior_gross()
ytd_gross = prior_gross + current_gross
deductions = self._ndfl_ytd_deductions(ytd_gross)
taxable = max(0.0, ytd_gross - deductions)
tax_ytd = self._ndfl_progressive_tax(taxable)
# Налог, исчисленный с дохода прошлых месяцев (для разницы месяца).
prior_deductions = self._ndfl_ytd_deductions_prior(prior_gross)
prior_taxable = max(0.0, prior_gross - prior_deductions)
tax_prior = self._ndfl_progressive_tax(prior_taxable)
tax_month = round(tax_ytd - tax_prior, 2)
return max(0.0, tax_month)
def _ndfl_ytd_deductions_prior(self, prior_gross):
"""Вычеты, накопленные к ПРЕДЫДУЩЕМУ месяцу (для разницы налога).
Аналог _ndfl_ytd_deductions, но за месяцы года ДО текущего.
"""
self.ensure_one()
employee = self.employee_id
if not employee.rsbu_allows_deductions():
return 0.0
months_prior = self._ndfl_payslip_months(include_current=False)
if months_prior <= 0:
return 0.0
month_child = sum(
child._deduction_amount(self.date_from)
for child in employee.rsbu_child_ids)
eligible_months = months_prior
if prior_gross > self.NDFL_CHILD_INCOME_LIMIT and months_prior > 0:
avg = prior_gross / months_prior
if avg > 0:
eligible_months = min(months_prior, int(
self.NDFL_CHILD_INCOME_LIMIT // avg))
child_deduction = month_child * max(0, eligible_months)
personal = self.NDFL_PERSONAL_DEDUCTION.get(
employee.rsbu_personal_deduction_code, 0.0) * months_prior
gto = self.NDFL_GTO_DEDUCTION if employee.rsbu_gto_deduction else 0.0
return round(child_deduction + personal + gto, 2)
# ------------------------------------------------------------------
# Показатели 6-НДФЛ по листку (для агрегации rsbu.ndfl.summary)
# ------------------------------------------------------------------
def _ndfl_report_values(self):
"""Показатели листка для 6-НДФЛ: начислено, вычеты, база, исчислено,
удержано, ставка (верхняя применённая), дата удержания.
Начислено (GROSS) и удержанный НДФЛ — из строк листка. Вычеты за
месяц — разница YTD-вычетов. База = начислено − вычеты (не ниже 0).
Исчислено для резидента ≈ удержано месяца. Ставка — верхняя ступень
прогрессивной шкалы, в которую попал доход YTD (для группировки
РасчСумНал по ставке).
"""
self.ensure_one()
gross = round(sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total')), 2)
withheld = round(abs(sum(self.line_ids.filtered(
lambda x: x.code == 'NDFL').mapped('total'))), 2)
employee = self.employee_id
# Вычет месяца = YTD-вычеты (вкл. текущий) − YTD-вычеты до текущего.
if employee.rsbu_allows_deductions():
prior_gross = self._ndfl_prior_gross()
ded_ytd = self._ndfl_ytd_deductions(prior_gross + gross)
ded_prior = self._ndfl_ytd_deductions_prior(prior_gross)
deduction = max(0.0, round(ded_ytd - ded_prior, 2))
else:
deduction = 0.0
base = max(0.0, round(gross - deduction, 2))
rate = self._ndfl_report_rate()
return {
'gross': gross, 'deduction': deduction, 'base': base,
'computed': withheld, 'withheld': withheld, 'rate': rate,
'withholding_date': (
self.rsbu_withholding_date
or self._rsbu_default_withholding_date()),
}
def _ndfl_report_rate(self):
"""Верхняя ставка прогрессивной шкалы по доходу YTD (в процентах).
Для нерезидента — 30%. Для резидента — ставка той ступени, в
которую попадает совокупный доход года (нарастающим итогом).
"""
self.ensure_one()
if not self.employee_id.rsbu_is_progressive_ndfl():
return int(round(self.NDFL_NONRESIDENT_RATE * 100))
gross = round(sum(self.line_ids.filtered(
lambda x: x.code == 'GROSS').mapped('total')), 2)
ytd = self._ndfl_prior_gross() + gross
lower = 0.0
rate = self.NDFL_BRACKETS[0][1]
for upper, r in self.NDFL_BRACKETS:
rate = r
if upper is None or ytd <= upper:
break
lower = upper
return int(round(rate * 100))
# ------------------------------------------------------------------
# Разбивка страховых взносов по видам для РСВ (ОПС/ОМС/ВНиМ)
# ------------------------------------------------------------------
def _rsbu_insurance_gross(self):
"""Начисленные выплаты (GROSS) текущего листка — для РСВ «Выплаты»."""
self.ensure_one()
by_code = {l.code: l.total for l in self.line_ids}
gross = sum(self.line_ids.filtered(
lambda x: x.category_id.code == 'RSBU_GROSS').mapped('total'))
if not gross:
gross = by_code.get('GROSS', 0.0)
return round(gross, 2)
def rsbu_insurance_base(self):
"""База страховых взносов текущего листка (GROSS − необлагаемое).
Необлагаемая часть — больничный (SICK3, не облагается взносами).
Аналогично правилу INSURANCE (base = RSBU_GROSS − SICK3). Читается
из строк листка; если строки ещё не сохранены (момент расчёта),
предпочтительно передавать base явно в rsbu_insurance_breakdown.
"""
self.ensure_one()
by_code = {l.code: l.total for l in self.line_ids}
gross = sum(self.line_ids.filtered(
lambda x: x.category_id.code == 'RSBU_GROSS').mapped('total'))
if not gross:
gross = by_code.get('GROSS', 0.0)
sick = by_code.get('SICK3', 0.0)
return max(0.0, gross - sick)
def _rsbu_insurance_ytd_base(self):
"""База страховых взносов сотрудника с начала года ДО текущего листка.
Нужна для регрессии: годовая предельная база ОПС считается
нарастающим итогом. Суммирует базу (GROSS − SICK3) по проведённым
листкам года, предшествующим текущему.
"""
self.ensure_one()
year_start = self.date_from.replace(month=1, day=1)
prior = self.search([
('employee_id', '=', self.employee_id.id),
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', year_start),
('date_from', '<', self.date_from),
('id', '!=', self.id),
])
total = 0.0
for slip in prior:
total += slip.rsbu_insurance_base()
return round(total, 2)
def rsbu_insurance_breakdown(self, base=None):
"""Разбивка взносов текущего листка по видам для РСВ: dict.
Возвращает {'ops', 'oms', 'vnim', 'total', 'base', 'base_regular',
'base_msp', 'base_within_limit', 'base_over_limit',
'ops_within', 'ops_over'}.
Базовый тариф применяется к части базы в пределах 1.5 МРОТ (при
льготе МСП) или ко всей базе; часть сверх 1.5 МРОТ — по льготному
совокупному тарифу МСП (ОПС/ОМС пропорционально, ВНиМ по льготе = 0).
Регрессия ОПС: годовая предельная база (rsbu_insurance_base_limit)
считается нарастающим итогом с начала года. База текущего листка
делится на часть в пределах лимита (base_within_limit) и сверх
(base_over_limit); взносы ОПС на превышение начисляются по
пониженному тарифу (для единого пониженного МСП сверх лимита — тот
же льготный тариф). ОМС предела не имеет.
"""
self.ensure_one()
company = self.company_id
if base is None:
base = self.rsbu_insurance_base()
empty = {'ops': 0.0, 'oms': 0.0, 'vnim': 0.0, 'total': 0.0,
'base': 0.0, 'base_regular': 0.0, 'base_msp': 0.0,
'base_within_limit': 0.0, 'base_over_limit': 0.0,
'ops_within': 0.0, 'ops_over': 0.0}
if base <= 0:
return empty
r_ops = (company.rsbu_rate_ops or 0.0) / 100.0
r_oms = (company.rsbu_rate_oms or 0.0) / 100.0
r_vnim = (company.rsbu_rate_vnim or 0.0) / 100.0
# --- Разбивка базы по годовой предельной базе (регрессия ОПС) ---
limit = company.rsbu_insurance_base_limit or 0.0
if limit > 0:
ytd = self._rsbu_insurance_ytd_base()
remaining = max(0.0, limit - ytd) # ещё в пределах лимита
base_within_limit = min(base, remaining)
base_over_limit = max(0.0, base - base_within_limit)
else:
base_within_limit = base
base_over_limit = 0.0
# --- Разбивка части В ПРЕДЕЛАХ лимита по порогу 1.5 МРОТ (МСП) ---
base_regular = base
base_msp = 0.0
if company.rsbu_msp_enabled and company.rsbu_min_wage:
threshold = 1.5 * company.rsbu_min_wage
base_regular = min(base, threshold)
base_msp = max(0.0, base - threshold)
# ОМС/ВНиМ считаем по базе в пределах 1.5 МРОТ (обычный тариф) плюс
# льготная часть МСП (ОМС — да, ВНиМ по льготе = 0). Предел базы
# к ОМС не применяется.
oms = base_regular * r_oms
vnim = base_regular * r_vnim
# ОПС в пределах лимита: часть base_within_limit по обычному тарифу
# (в пределах 1.5 МРОТ) + льготному (сверх 1.5 МРОТ).
# Для простоты и корректности начисления считаем ОПС отдельно по
# обычной и льготной частям всей базы, затем разносим на within/over.
ops_regular = base_regular * r_ops
ops_msp = 0.0
if base_msp:
r_msp = (company.rsbu_msp_rate or 0.0) / 100.0
msp_total = base_msp * r_msp
share = (r_ops + r_oms) or 1.0
ops_msp = msp_total * (r_ops / share)
oms += msp_total * (r_oms / share)
ops_total = ops_regular + ops_msp
# Разнесение ОПС на within/over по доле базы в пределах лимита.
if base > 0:
frac_within = base_within_limit / base
else:
frac_within = 1.0
ops_within = round(ops_total * frac_within, 2)
ops_over = round(ops_total - ops_within, 2)
return {
'ops': round(ops_total, 2),
'oms': round(oms, 2),
'vnim': round(vnim, 2),
'total': round(ops_total + oms + vnim, 2),
'base': round(base, 2),
'base_regular': round(base_regular, 2),
'base_msp': round(base_msp, 2),
'base_within_limit': round(base_within_limit, 2),
'base_over_limit': round(base_over_limit, 2),
'ops_within': ops_within,
'ops_over': ops_over,
}
# ------------------------------------------------------------------
# Учёт отсутствий из hr.leave: раздельные коды по виду отсутствия
# ------------------------------------------------------------------
# Код строки worked_days по виду отсутствия (rsbu_leave_kind):
RSBU_LEAVE_CODE = {
'vacation': 'LV_VACATION',
'sick': 'LV_SICK',
'unpaid': 'LV_UNPAID',
'other': 'LV_OTHER',
}
def _compute_leave_days(self, contract, day_from, day_to):
"""Строки отсутствий с раздельными кодами по виду (РСБУ).
Штатный payroll сваливает все отсутствия в код GLOBAL с
отрицательным числом дней. Нам нужно различать отпуск/больничный/
без содержания, чтобы правила расчёта тянули данные автоматически.
Формируем по одной строке worked_days на КАЖДЫЙ вид отсутствия
(rsbu_leave_kind типа отсутствия), с ПОЛОЖИТЕЛЬНЫМ числом дней/часов —
так формулам удобнее (дни отсутствия каждого вида).
"""
self.ensure_one()
from pytz import timezone
from datetime import datetime, time
leaves = {}
calendar = contract.resource_calendar_id
tz = timezone(calendar.tz or contract.employee_id.tz or 'UTC')
day_leave_intervals = contract.employee_id.list_leaves(
day_from, day_to, calendar=calendar)
for day, hours, leave in day_leave_intervals:
holiday = leave[:1].holiday_id
leave_type = holiday.holiday_status_id
kind = leave_type.rsbu_leave_kind or 'other'
code = self.RSBU_LEAVE_CODE.get(kind, 'LV_OTHER')
struct = leaves.setdefault(code, {
'name': leave_type.name or _('Отсутствие'),
'sequence': 20,
'code': code,
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract.id,
})
struct['number_of_hours'] += hours
work_hours = calendar.get_work_hours_count(
tz.localize(datetime.combine(day, time.min)),
tz.localize(datetime.combine(day, time.max)),
compute_leaves=False)
if work_hours:
struct['number_of_days'] += hours / work_hours
return list(leaves.values())
# ------------------------------------------------------------------
# Точки расширения rsbu.posting.mixin
# ------------------------------------------------------------------
def _rsbu_is_applicable(self):
"""Проводку формируем для проведённого расчётного листка."""
self.ensure_one()
return self.state == 'done'
def _rsbu_move_date(self):
"""Дата проводки начисления — конец расчётного периода."""
self.ensure_one()
return self.date_to or fields.Date.context_today(self)
def _rsbu_get_move_values(self):
vals = super()._rsbu_get_move_values()
# Начисление зарплаты в КУДиР (кассовый метод) не участвует —
# признание идёт при выплате/уплате. Помечаем, чтобы пересчёт
# КУДиР не подхватывал начисление.
vals['usn_recognition'] = 'none'
return vals
def _rsbu_build_lines(self):
"""Собрать строки rsbu.move.line из строк расчёта.
По каждому правилу с суммой и заданными счетами РСБУ формируем
две строки (дебет и кредит) на модуль суммы. Отрицательные суммы
(удержания) и режим сторно (`credit_note`) инвертируют стороны.
Субконто «сотрудник» проставляется, где включено на правиле
(обязательно для счёта 70).
"""
self.ensure_one()
lines = []
employee = self.employee_id
for pline in self.line_ids:
rule = pline.salary_rule_id
debit_account = rule.rsbu_account_debit
credit_account = rule.rsbu_account_credit
# Правило без счетов РСБУ не порождает проводку (напр. GROSS/NET —
# промежуточные итоги, счетов не имеют).
if not debit_account and not credit_account:
continue
# Направление проводки задаётся счетами правила (Дт/Кт), а сумма —
# модуль строки расчёта. Знак total лишь отражает роль правила в
# расчёте NET (удержания заданы «минусом»), но не направление
# проводки. Сторно (credit_note) инвертирует стороны.
amount = abs(pline.total or 0.0)
if not amount:
continue
invert = bool(self.credit_note)
base_subconto = {}
if rule.rsbu_subconto_employee and employee:
base_subconto['employee_id'] = employee.id
if debit_account:
lines.append(self._rsbu_payroll_line(
debit_account, pline.name,
debit=0.0 if invert else amount,
credit=amount if invert else 0.0,
subconto=base_subconto))
if credit_account:
lines.append(self._rsbu_payroll_line(
credit_account, pline.name,
debit=amount if invert else 0.0,
credit=0.0 if invert else amount,
subconto=base_subconto))
return lines
def _rsbu_payroll_line(self, account, name, debit=0.0, credit=0.0, subconto=None):
"""Хелпер значений строки rsbu.move.line.
Субконто «сотрудник» на счёте требуется планом счетов только для 70;
проставляем его на всех строках с включённым флагом (безопасно —
для прочих счетов это просто аналитика).
"""
vals = {
'account_id': account.id,
'name': name or _('Начисление зарплаты'),
'debit': debit,
'credit': credit,
}
if subconto:
vals.update(subconto)
# Счёт может обязательно требовать субконто «сотрудник» (напр. 70):
# если правило не проставило employee, но счёт требует — подставим.
if 'employee_id' not in vals and account.subconto_employee and self.employee_id:
vals['employee_id'] = self.employee_id.id
return vals