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,6 @@
from . import res_company
from . import res_config_settings
from . import rsbu_operation_template
from . import rsbu_move
from . import rsbu_kudir_line
from . import rsbu_kudir_summary

View File

@@ -0,0 +1,26 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
rsbu_usn_mode = fields.Selection(
selection=[
('usn_income', 'УСН «Доходы»'),
('usn_income_expense', 'УСН «Доходы минус расходы»'),
],
string='Режим УСН', default='usn_income')
rsbu_usn_rate = fields.Float(
string='Ставка УСН, %', default=6.0,
help='Ставка налога при УСН (6% для «Доходы», 15% для «Доходы минус '
'расходы»; может быть снижена региональным законом).')
rsbu_usn_reduction_limit = fields.Float(
string='Лимит уменьшения на взносы, %', default=50.0,
help='Предел уменьшения налога УСН «Доходы» на страховые взносы и '
'пособия: 50% для организаций и ИП с работниками, 100% для ИП '
'без работников.')
rsbu_accounting_start = fields.Date(
string='Дата начала учёта РСБУ',
help='Дата, с которой ведётся учёт в РСБУ-слое. Месяц этой даты — '
'первый доступный для закрытия периода; более ранние месяцы '
'закрывать не требуется и нельзя.')

View File

@@ -0,0 +1,12 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
rsbu_usn_mode = fields.Selection(
related='company_id.rsbu_usn_mode', readonly=False,
string='Режим УСН')
rsbu_accounting_start = fields.Date(
related='company_id.rsbu_accounting_start', readonly=False,
string='Дата начала учёта РСБУ')

View File

@@ -0,0 +1,171 @@
from odoo import api, fields, models
# Коды денежных счетов (по плану 94н): касса и расчётные счета.
MONEY_ACCOUNT_CODES = ('50', '51', '52', '55', '57')
# Код счёта расчётов по страховым взносам (для раздела IV).
INSURANCE_ACCOUNT_CODE = '69'
class RsbuKudirLine(models.Model):
"""Запись КУДиР (кассовый слой поверх журнала)."""
_name = 'rsbu.kudir.line'
_description = 'РСБУ — Запись КУДиР'
_order = 'date, id'
_check_company_auto = True
date = fields.Date(string='Дата', required=True, index=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
section = fields.Selection(
selection=[
('section_1', 'Раздел I. Доходы и расходы'),
('section_4', 'Раздел IV. Страховые взносы'),
],
string='Раздел', required=True, index=True)
move_id = fields.Many2one('rsbu.move', string='Операция-источник',
ondelete='cascade')
move_line_id = fields.Many2one('rsbu.move.line', string='Строка журнала',
ondelete='cascade')
document_ref = fields.Char(string='Первичный документ')
narration = fields.Char(string='Содержание операции')
income_amount = fields.Monetary(string='Доходы',
currency_field='currency_id')
expense_amount = fields.Monetary(string='Расходы',
currency_field='currency_id')
is_refund = fields.Boolean(string='Возврат')
currency_id = fields.Many2one('res.currency', string='Валюта',
compute='_compute_currency_id', store=True)
period_key = fields.Char(string='Период', compute='_compute_period_key',
store=True, index=True)
@api.depends('company_id')
def _compute_currency_id(self):
for line in self:
line.currency_id = line.company_id.currency_id
@api.depends('date')
def _compute_period_key(self):
for line in self:
if line.date:
quarter = (line.date.month - 1) // 3 + 1
line.period_key = '%s-Q%s' % (line.date.year, quarter)
else:
line.period_key = False
# ------------------------------------------------------------------
# Пересчёт
# ------------------------------------------------------------------
@api.model
def _is_money_account(self, account):
code = (account.code or '').split('.')[0]
return code in MONEY_ACCOUNT_CODES
@api.model
def _is_insurance_account(self, account):
code = (account.code or '').split('.')[0]
return code == INSURANCE_ACCOUNT_CODE
@api.model
def recompute_period(self, date_from, date_to, company):
"""Пересобрать записи КУДиР за период из проведённых проводок.
Идемпотентно: сначала удаляет существующие записи периода,
затем создаёт заново.
TODO(perf): при больших объёмах вынести в cron пакетного
пересчёта. Сейчас крон не реализуется (см. спецификацию Э5 R6.4).
"""
# 1) Удалить существующие записи за период.
self.search([
('company_id', '=', company.id),
('date', '>=', date_from),
('date', '<=', date_to),
]).unlink()
usn_mode = company.rsbu_usn_mode
# Отбираем проводки, участвующие в УСН (доход/расход/возврат), а также
# проводки с корректировкой дохода при отгрузке (переходящие сделки —
# см. kudir_income_adjust), даже если сама проводка не признаётся
# доходом по кассе (usn_recognition='none').
moves = self.env['rsbu.move'].search([
('company_id', '=', company.id),
('state', '=', 'posted'),
('date', '>=', date_from),
('date', '<=', date_to),
'|',
'&', ('usn_recognition', 'not in', ('none', False)),
(1, '=', 1),
('kudir_income_adjust', '!=', 0.0),
])
created = self.browse()
for move in moves:
created |= self._build_lines_for_move(move, usn_mode)
return created
@api.model
def _build_lines_for_move(self, move, usn_mode):
vals_list = []
recognition = move.usn_recognition
# Корректировка дохода при отгрузке в счёт входящего аванса
# (переходящая сделка УСН→УСН+НДС): отдельная строка раздела I на
# сумму kudir_income_adjust (обычно отрицательная), не связанная с
# движением денег. Первичный документ/содержание — от проводки
# реализации (документ отгрузки).
adjust = getattr(move, 'kudir_income_adjust', 0.0) or 0.0
if adjust:
vals_list.append(self._line_vals(
move, move.line_ids[:1], 'section_1',
income_amount=round(adjust, 2)))
for line in move.line_ids:
account = line.account_id
# Раздел IV: уплата страховых взносов (дебет счёта 69).
if self._is_insurance_account(account) and line.debit:
vals_list.append(self._line_vals(
move, line, 'section_4',
expense_amount=line.debit))
continue
# Раздел I: движение денег.
if not self._is_money_account(account):
continue
amount = line.debit or line.credit
if recognition == 'income' and line.debit:
# Доход УСН = поступление минус предъявленный НДС (на УСН+НДС
# НДС в доход не включается). НДС берётся из документа-
# источника (move.kudir_income_vat), для необлагаемых = 0.
vat = getattr(move, 'kudir_income_vat', 0.0) or 0.0
income = round(amount - vat, 2)
vals_list.append(self._line_vals(
move, line, 'section_1', income_amount=income))
elif recognition == 'refund_customer' and line.credit:
vals_list.append(self._line_vals(
move, line, 'section_1', income_amount=-amount,
is_refund=True))
elif recognition == 'expense' and line.credit \
and usn_mode == 'usn_income_expense':
vals_list.append(self._line_vals(
move, line, 'section_1', expense_amount=amount))
elif recognition == 'refund_supplier' and line.debit \
and usn_mode == 'usn_income_expense':
vals_list.append(self._line_vals(
move, line, 'section_1', expense_amount=-amount,
is_refund=True))
if not vals_list:
return self.browse()
return self.create(vals_list)
@api.model
def _line_vals(self, move, line, section, income_amount=0.0,
expense_amount=0.0, is_refund=False):
return {
'date': move.date,
'company_id': move.company_id.id,
'section': section,
'move_id': move.id,
'move_line_id': line.id,
'document_ref': move.name,
'narration': move.ref or line.name or '',
'income_amount': income_amount,
'expense_amount': expense_amount,
'is_refund': is_refund,
}

View File

@@ -0,0 +1,95 @@
"""Регистр промежуточных итогов КУДиР по кварталам (для декларации УСН).
Аналог rsbu.vat.summary: агрегирует записи rsbu.kudir.line за квартал —
доходы и расходы раздела I и уплаченные взносы/пособия раздела IV. Служит
источником для декларации УСН (нарастающим итогом по отчётным периодам).
"""
from odoo import api, fields, models
class RsbuKudirSummary(models.Model):
_name = 'rsbu.kudir.summary'
_description = 'РСБУ — Итоги КУДиР за период'
_order = 'period_key desc'
_check_company_auto = True
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
period_key = fields.Char(string='Квартал', required=True, index=True)
income = fields.Monetary(string='Доходы (раздел I)',
currency_field='currency_id')
expense = fields.Monetary(string='Расходы (раздел I)',
currency_field='currency_id')
insurance = fields.Monetary(string='Взносы/пособия (раздел IV)',
currency_field='currency_id')
currency_id = fields.Many2one('res.currency', string='Валюта',
compute='_compute_currency_id', store=True)
@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):
"""Пересчитать итоги КУДиР по кварталам периода из записей книги.
Идемпотентно: сначала пересобирает сами записи КУДиР за период
(rsbu.kudir.line.recompute_period), затем агрегирует по кварталам.
"""
Line = self.env['rsbu.kudir.line']
Line.recompute_period(date_from, date_to, company)
lines = Line.search([
('company_id', '=', company.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
by_period = {}
for ln in lines:
data = by_period.setdefault(
ln.period_key,
{'income': 0.0, 'expense': 0.0, 'insurance': 0.0})
if ln.section == 'section_1':
data['income'] += ln.income_amount
data['expense'] += ln.expense_amount
elif ln.section == 'section_4':
data['insurance'] += ln.expense_amount
result = self.browse()
for period_key, data in by_period.items():
existing = self.search([
('company_id', '=', company.id),
('period_key', '=', period_key)], limit=1)
vals = {
'company_id': company.id,
'period_key': period_key,
'income': data['income'],
'expense': data['expense'],
'insurance': data['insurance'],
}
if existing:
existing.write(vals)
result |= existing
else:
result |= self.create(vals)
return result
@api.model
def ytd_totals(self, company, year, up_to_quarter):
"""Итоги нарастающим итогом с начала года по квартал включительно.
Возвращает dict {income, expense, insurance} — сумма по кварталам
year-Q1 … year-Q{up_to_quarter}. Для декларации УСН (отчётные
периоды нарастающим итогом).
"""
keys = ['%s-Q%s' % (year, q) for q in range(1, up_to_quarter + 1)]
recs = self.search([
('company_id', '=', company.id),
('period_key', 'in', keys),
])
return {
'income': round(sum(recs.mapped('income')), 2),
'expense': round(sum(recs.mapped('expense')), 2),
'insurance': round(sum(recs.mapped('insurance')), 2),
}

View File

@@ -0,0 +1,174 @@
from odoo import fields, models
# Расчётная доля НДС в сумме по режиму НДС компании (аналог ставки 5/105).
_ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
class RsbuMove(models.Model):
"""Расширение операции: перенос правила признания УСН (для КУДиР)."""
_inherit = 'rsbu.move'
usn_recognition = fields.Selection(
selection=[
('income', 'Доход'),
('expense', 'Расход'),
('refund_customer', 'Возврат клиенту'),
('refund_supplier', 'Возврат поставщику'),
('none', 'Не участвует в УСН'),
],
string='Признание в УСН', default='none', copy=False)
is_refund = fields.Boolean(string='Это возврат', copy=False)
kudir_income_vat = fields.Monetary(
string='НДС, исключаемый из дохода УСН',
currency_field='currency_id', default=0.0, copy=False,
help='Сумма предъявленного НДС по поступлению, которая исключается '
'из дохода КУДиР (доход = поступление − этот НДС). На УСН+НДС '
'заполняется из налога документа; для необлагаемых операций 0.')
kudir_income_adjust = fields.Monetary(
string='Корректировка дохода УСН при отгрузке',
currency_field='currency_id', default=0.0, copy=False,
help='Отрицательная корректировка дохода КУДиР в момент отгрузки в '
'счёт ранее полученного аванса, по которому НДС не был исключён '
'при поступлении (переходящая сделка: аванс получен до перехода '
'на НДС, отгрузка облагается по ставке). Равна −(зачтённый аванс '
'× расчётная ставка НДС). См. п.1 ст.248, п.1 ст.346.15 НК РФ.')
class AccountMove(models.Model):
_inherit = 'account.move'
def _rsbu_is_calibration(self):
"""Документ создан калибровкой (несёт ключ 1С). Для таких проводок
поля КУДиР-НДС размечает калибровочный слой по ОСВ-эталону, а не
онлайн-логика проведения."""
self.ensure_one()
return bool(getattr(self, 'calibration_ref_key', False))
def _rsbu_get_move_values(self):
vals = super()._rsbu_get_move_values()
template = self.rsbu_template_id or self._rsbu_detect_template()
if template:
vals['usn_recognition'] = template.usn_recognition
vals['is_refund'] = template.is_refund
# Корректировка дохода КУДиР при отгрузке в счёт аванса, по
# которому НДС не был исключён при поступлении (переходящая
# сделка УСН→УСН+НДС). Только для облагаемой отгрузки.
# Калибровочные отгрузки размечаются mark_kudir_shipment_adjust
# (по ОСВ-эталону входящих авансов) — онлайн-расчёт пропускаем.
if template.resolver_key == 'shipment_upd' and self.amount_tax \
and not self._rsbu_is_calibration():
adjust = self._rsbu_kudir_shipment_adjust(template)
if adjust:
vals['kudir_income_adjust'] = adjust
return vals
def _rsbu_kudir_shipment_adjust(self, template):
"""Отрицательная корректировка дохода КУДиР при отгрузке.
Проходит зачитываемые отгрузкой авансы (FIFO по основанию, как в
резолвере shipment_upd). Для авансов, по которым НДС не был исключён
при поступлении (origin.kudir_income_vat == 0), начисляет исключение
сейчас: −(зачтённая сумма × расчётная доля НДС). Возвращает <= 0.
"""
self.ensure_one()
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
if not fraction:
return 0.0
advance = template.sudo()._account_or_none('advance_received')
partner = self.partner_id
if not advance or not partner:
return 0.0
subconto = {'partner': partner.id}
contract = getattr(self, 'mt_contract_id', False)
if contract:
subconto['contract'] = contract.id
origins = advance.get_origin_balances(
date_to=self.date, company_id=self.company_id.id,
subconto=subconto)
remaining = abs(self.amount_total)
corr_base = 0.0
for data in origins:
if remaining <= 0:
break
available = data['balance'] if data['side'] == 'credit' else 0.0
if available <= 0:
continue
take = round(min(available, remaining), 2)
if take <= 0:
continue
origin_move = data['origin']
# НДС по этому авансу при поступлении не исключался → исключаем
# соответствующую долю сейчас, при отгрузке.
if not (origin_move.kudir_income_vat or 0.0):
corr_base += take
remaining -= take
if corr_base <= 0:
return 0.0
return -round(corr_base * fraction, 2)
class AccountPayment(models.Model):
_inherit = 'account.payment'
def _rsbu_is_calibration(self):
"""См. AccountMove._rsbu_is_calibration."""
self.ensure_one()
return bool(getattr(self, 'calibration_ref_key', False))
def _rsbu_get_move_values(self):
vals = super()._rsbu_get_move_values()
template = self.rsbu_template_id or self._rsbu_detect_template()
if template:
vals['usn_recognition'] = template.usn_recognition
vals['is_refund'] = template.is_refund
# НДС, исключаемый из дохода КУДиР (доход = поступление − НДС).
# Только для доходных поступлений от покупателя (кассовый метод
# УСН, п.1 ст.248/346.15 НК: доход без предъявленного НДС).
# Калибровочные проводки размечаются отдельно (mark_kudir_vat по
# ОСВ-эталону) — онлайн-расчёт для них пропускаем.
if template.usn_recognition == 'income' \
and not self._rsbu_is_calibration():
vals['kudir_income_vat'] = self._rsbu_kudir_income_vat()
return vals
def _rsbu_kudir_income_vat(self):
"""НДС, исключаемый из дохода КУДиР по клиентскому поступлению.
Приоритет — явно указанный НДС платежа (rsbu_vat_amount: банковская
выписка/оператор). Иначе, если поступление относится к облагаемой
НДС реализации (инвойс того же контрагента и договора несёт налог —
«инвойс источник правды»), исключаем расчётную долю НДС из суммы.
Для необлагаемых операций (лицензии и т.п.) — 0.
"""
self.ensure_one()
vat = abs(self.rsbu_vat_amount or 0.0)
if vat:
return round(vat, 2)
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
if fraction and self._rsbu_payment_is_taxable():
return round(abs(self.amount) * fraction, 2)
return 0.0
def _rsbu_payment_is_taxable(self):
"""Облагается ли НДС реализация, к которой относится поступление.
Инвойс — источник правды об облагаемости: ищем out_invoice того же
контрагента и договора (mt_contract_id) с ненулевым налогом. Договор
в смешанной сделке (лицензия без НДС + услуги с НДС) разграничивает
облагаемость по конкретной реализации.
"""
self.ensure_one()
partner = self.partner_id
if not partner:
return False
Move = self.env['account.move'].sudo()
domain = [
('move_type', '=', 'out_invoice'),
('company_id', '=', self.company_id.id),
('partner_id', '=', partner.id),
('amount_tax', '>', 0),
]
contract = getattr(self, 'mt_contract_id', False)
if contract:
domain.append(('mt_contract_id', '=', contract.id))
return bool(Move.search(domain, limit=1))

View File

@@ -0,0 +1,17 @@
from odoo import fields, models
class RsbuOperationTemplate(models.Model):
"""Расширение шаблона: правило признания в УСН (для КУДиР)."""
_inherit = 'rsbu.operation.template'
usn_recognition = fields.Selection(
selection=[
('income', 'Доход'),
('expense', 'Расход'),
('refund_customer', 'Возврат клиенту'),
('refund_supplier', 'Возврат поставщику'),
('none', 'Не участвует в УСН'),
],
string='Признание в УСН', default='none', required=True)
is_refund = fields.Boolean(string='Это возврат')