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,8 @@
from . import res_company
from . import res_config_settings
from . import rsbu_invoice
from . import rsbu_realization
from . import rsbu_vat_book_line
from . import rsbu_vat_summary
from . import rsbu_period_close
from . import account_payment

View File

@@ -0,0 +1,76 @@
"""Автосоздание счёта-фактуры на аванс при поступлении аванса от покупателя.
Плательщик УСН, исчисляющий НДС, при получении аванса обязан начислить
авансовый НДС и выставить счёт-фактуру на аванс. В РСБУ-слое этому
соответствует rsbu.invoice(advance), порождающий проводку Дт 76.АВ —
Кт 68.02. Здесь такой СФ создаётся автоматически при проведении платежа,
если поступление образует аванс по облагаемой НДС сделке.
"""
from odoo import models
# Ставочные режимы НДС на УСН (при них начисляется авансовый НДС).
_VAT_RATE_MODES = ('vat_5', 'vat_7')
class AccountPayment(models.Model):
_inherit = 'account.payment'
def action_post(self):
res = super().action_post()
for payment in self.filtered(
lambda p: p.state in ('in_process', 'paid')):
payment._rsbu_autocreate_advance_invoice()
return res
def _rsbu_autocreate_advance_invoice(self):
"""Создать и подтвердить авансовый СФ по поступившему авансу.
Условия: входящий клиентский платёж, породивший аванс (Кт счёта
advance_received в РСБУ-проводке); режим НДС компании — ставочный;
сделка облагается НДС (инвойс источник правды). Не выполняется для
калибровочных проводок (их авансовые СФ строит калибровочный слой)
и идемпотентно (повторно СФ по той же проводке-авансу не создаётся).
"""
self.ensure_one()
if self.env.context.get('rsbu_skip'):
return
# Калибровочные документы размечает калибровочный слой.
if getattr(self, '_rsbu_is_calibration', None) \
and self._rsbu_is_calibration():
return
if self.company_id.rsbu_vat_mode not in _VAT_RATE_MODES:
return
move = self.rsbu_move_id
if not move:
return
# Облагаемость: инвойс того же контрагента/договора с НДС.
if not (getattr(self, '_rsbu_payment_is_taxable', None)
and self._rsbu_payment_is_taxable()):
return
Invoice = self.env['rsbu.invoice'].sudo()
advance_acc = Invoice._role_account_for_company(
'advance_received', self.company_id)
if not advance_acc:
return
# Строки-авансы этой проводки (Кт advance_received, origin = сама
# проводка). По каждому авансу — свой СФ (дроблёный вычет по origin).
advance_lines = move.line_ids.filtered(
lambda l: l.account_id == advance_acc and l.credit > 0)
for line in advance_lines:
origin = line.origin_move_id or move
# Идемпотентность: СФ по этому авансу-основанию уже есть.
if Invoice.search_count([
('origin_move_id', '=', origin.id),
('invoice_type', '=', 'advance'),
('company_id', '=', self.company_id.id)]):
continue
invoice = Invoice.create({
'invoice_type': 'advance',
'partner_id': line.partner_id.id or self.partner_id.id,
'company_id': self.company_id.id,
'date': move.date,
'base_amount': line.credit,
'origin_move_id': origin.id,
})
invoice.action_confirm()

View File

@@ -0,0 +1,13 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
rsbu_vat_mode = fields.Selection(
selection=[
('vat_exempt', 'Освобождён от НДС'),
('vat_5', 'Спецставка 5%'),
('vat_7', 'Спецставка 7%'),
],
string='Режим НДС на УСН', default='vat_exempt')

View File

@@ -0,0 +1,9 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
rsbu_vat_mode = fields.Selection(
related='company_id.rsbu_vat_mode', readonly=False,
string='Режим НДС на УСН')

View File

@@ -0,0 +1,175 @@
from odoo import api, fields, models
class RsbuInvoice(models.Model):
"""Счёт-фактура РСБУ — источник расчёта НДС.
Типы: авансовый (при получении аванса), отгрузочный (при УПД),
корректировочный (возврат / изменение стоимости).
Авансовый СФ при подтверждении порождает РСБУ-проводку начисления
авансового НДС (Дт 76.АВ — Кт 68.НДС) через rsbu.posting.mixin, как
отдельный документ (аналог «Счёта-фактуры выданного» на аванс в 1С).
"""
_name = 'rsbu.invoice'
_description = 'РСБУ — Счёт-фактура'
_order = 'date desc, name desc, id desc'
_inherit = ['rsbu.posting.mixin']
_check_company_auto = True
name = fields.Char(string='Номер СФ', default='/', copy=False,
index=True)
date = fields.Date(string='Дата СФ', required=True, index=True,
default=fields.Date.context_today)
invoice_type = fields.Selection(
selection=[
('advance', 'Авансовый'),
('shipment', 'Отгрузочный (УПД)'),
('correction', 'Корректировочный'),
],
string='Тип СФ', required=True, default='advance')
partner_id = fields.Many2one('res.partner', string='Контрагент',
required=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
currency_id = fields.Many2one(
'res.currency', string='Валюта', compute='_compute_currency_id',
store=True)
# Связь с источником (rsbu_move_id приходит из rsbu.posting.mixin).
source_model = fields.Char(string='Модель источника', copy=False)
source_res_id = fields.Integer(string='ID источника', copy=False)
# Проводка-аванс (документ-основание) — для дроблёного вычета НДС.
origin_move_id = fields.Many2one(
'rsbu.move', string='Документ-основание (аванс)', copy=False,
help='Проводка получения аванса, по которому начислен авансовый НДС.')
vat_rate = fields.Selection(
selection=[('vat_exempt', 'Без НДС'), ('vat_5', '5%'),
('vat_7', '7%'), ('vat_20', '20%'), ('vat_10', '10%')],
string='Ставка НДС', default='vat_5')
base_amount = fields.Monetary(string='Налоговая база',
currency_field='currency_id')
vat_amount = fields.Monetary(string='Сумма НДС',
currency_field='currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('confirmed', 'Подтверждён'),
('cancelled', 'Отменён')],
string='Состояние', default='draft', copy=False)
# Связь с документом реализации (у отгрузочного СФ). У авансового и
# корректировочного — NULL. Передаточная часть УПД (upd_*) перенесена на
# rsbu.realization.
realization_id = fields.Many2one(
'rsbu.realization', string='Документ реализации',
copy=False, ondelete='set null')
# Исходный СФ для корректировочного СФ (КСФ) — схема-задел, без поведения.
origin_invoice_id = fields.Many2one(
'rsbu.invoice', string='Исходный счёт-фактура (для КСФ)',
copy=False, ondelete='set null')
@api.depends('company_id')
def _compute_currency_id(self):
for inv in self:
inv.currency_id = inv.company_id.currency_id
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', '/') == '/':
seq = self.env['ir.sequence'].with_company(
vals.get('company_id') or self.env.company.id)
vals['name'] = seq.next_by_code('rsbu.invoice') or '/'
return super().create(vals_list)
# ------------------------------------------------------------------
# Расчётная доля НДС в авансе по режиму НДС компании.
_ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
def _role_account(self, role):
"""Счёт РСБУ по роли маппинга (vat_output/vat_advance) для компании."""
self.ensure_one()
return self._role_account_for_company(role, self.company_id)
@api.model
def _role_account_for_company(self, role, company):
"""Счёт РСБУ по роли маппинга для указанной компании (без записи)."""
Role = self.env['rsbu.operation.template.role'].sudo()
rec = Role.search([
('role', '=', role),
('company_id', 'in', (False, company.id)),
], limit=1)
return rec.account_id
# ------------------------------------------------------------------
# Точки rsbu.posting.mixin
# ------------------------------------------------------------------
def _rsbu_is_applicable(self):
"""Проводку порождает только авансовый СФ с НДС и заданными счетами."""
self.ensure_one()
if self.invoice_type != 'advance':
return False
if not self._role_account('vat_advance') \
or not self._role_account('vat_output'):
return False
has_vat = bool(self.vat_amount) or bool(
self._ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode))
return has_vat
def _rsbu_move_date(self):
self.ensure_one()
return self.date or fields.Date.context_today(self)
def _rsbu_build_lines(self):
"""Начисление авансового НДС: Дт 76.АВ — Кт 68.НДС.
Сумма — vat_amount, если задан, иначе base_amount×доля режима НДС.
origin строки 76.АВ = проводка-аванс (origin_move_id) для дроблёного
вычета в регламенте книги покупок.
"""
self.ensure_one()
vat_advance = self._role_account('vat_advance')
vat_output = self._role_account('vat_output')
if not vat_advance or not vat_output:
return []
vat = self.vat_amount
if not vat:
fraction = self._ADVANCE_VAT_FRACTION.get(
self.company_id.rsbu_vat_mode, 0.0)
vat = round((self.base_amount or 0.0) * fraction, 2)
if vat <= 0:
return []
dt = {'account_id': vat_advance.id, 'debit': vat,
'name': 'НДС с аванса'}
if self.partner_id:
dt['partner_id'] = self.partner_id.id
if self.origin_move_id:
dt['origin_move_id'] = self.origin_move_id.id
kt = {'account_id': vat_output.id, 'credit': vat,
'name': 'НДС с аванса начислен'}
return [dt, kt]
def action_confirm(self):
self.write({'state': 'confirmed'})
for inv in self:
inv._rsbu_generate()
def action_cancel(self):
self._rsbu_remove()
self.write({'state': 'cancelled'})
# ------------------------------------------------------------------
# Связь с инвойсом-источником (для товарных позиций УПД, Вариант B)
# ------------------------------------------------------------------
def _source_invoice(self):
"""Вернуть документ-источник (account.move), если СФ создан из него.
Товарные позиции УПД (наименование/кол-во/ОКЕИ/ГТИН) берутся из
инвойса Odoo, а финансовые показатели СФ — из РСБУ-слоя (Вариант B).
"""
self.ensure_one()
if self.source_model == 'account.move' and self.source_res_id:
move = self.env['account.move'].browse(self.source_res_id)
return move if move.exists() else self.env['account.move']
return self.env['account.move']

View File

@@ -0,0 +1,645 @@
"""Регламентные операции закрытия периода (РСБУ, УСН+НДС).
Документ конца квартала, порождающий регламентные РСБУ-проводки, которых
нет среди первичных документов:
* вычет авансового НДС по зачтённым облагаемым авансам
(Дт vat_output(68.02) — Кт vat_advance(76.АВ)) — «формирование записей
книги покупок»;
* перенос налогов на единый налоговый счёт (ЕНС):
Дт 68.02 — Кт 68.90 (НДС), Дт 68.12 — Кт 68.90 (УСН);
* начисление налога УСН: Дт 99.01.1 — Кт 68.12.
Каждая операция создаёт отдельную rsbu.move (сводную регламентную) и
хранит ссылку для отмены/переигрывания. Суммы берутся из сальдо/оборотов
счётов РСБУ-журнала (get_balance / get_origin_balances), без внешних данных.
"""
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
class RsbuPeriodClose(models.Model):
_name = 'rsbu.period.close'
_description = 'РСБУ — Закрытие периода (регламент)'
_order = 'date_to desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
# Закрытие помесячное (как в 1С). Период задаётся месяцем; границы
# date_from/date_to вычисляются как первый/последний день месяца.
period_month = fields.Date(
string='Месяц закрытия', required=True,
default=lambda self: fields.Date.today().replace(day=1),
help='Любая дата месяца; закрытие выполняется за календарный месяц.')
date_from = fields.Date(string='Период с', compute='_compute_period',
store=True)
date_to = fields.Date(string='Период по', compute='_compute_period',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
move_ids = fields.Many2many(
'rsbu.move', string='Регламентные проводки', copy=False)
move_count = fields.Integer(compute='_compute_move_count')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('done', 'Проведён')],
default='draft', string='Состояние', copy=False)
@api.constrains('period_month', 'company_id')
def _check_unique_month(self):
for rec in self:
if not rec.period_month:
continue
dup = self.search([
('id', '!=', rec.id),
('company_id', '=', rec.company_id.id),
('period_month', '>=', rec.date_from),
('period_month', '<=', rec.date_to),
], limit=1)
if dup:
raise ValidationError(_(
'Закрытие за этот месяц уже существует: %s.') % dup.name)
_MONTH_NAMES = [
'', 'январь', 'февраль', 'март', 'апрель', 'май', 'июнь',
'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь']
@api.depends('period_month', 'company_id')
def _compute_period(self):
"""Границы календарного месяца по period_month."""
import calendar
for rec in self:
if not rec.period_month:
rec.date_from = rec.date_to = False
continue
first = rec.period_month.replace(day=1)
last_day = calendar.monthrange(first.year, first.month)[1]
rec.date_from = first
rec.date_to = first.replace(day=last_day)
@api.depends('period_month')
def _compute_name(self):
for rec in self:
if rec.period_month:
rec.name = _('Закрытие: %(m)s %(y)s',
m=self._MONTH_NAMES[rec.period_month.month],
y=rec.period_month.year)
else:
rec.name = _('Закрытие периода')
def _is_quarter_end(self):
"""Месяц — последний в квартале (март/июнь/сентябрь/декабрь)."""
self.ensure_one()
return bool(self.period_month) and self.period_month.month in (3, 6, 9, 12)
def _is_year_end(self):
"""Месяц — декабрь (для реформации баланса)."""
self.ensure_one()
return bool(self.period_month) and self.period_month.month == 12
@api.depends('move_ids')
def _compute_move_count(self):
for rec in self:
rec.move_count = len(rec.move_ids)
# ------------------------------------------------------------------
def _acc(self, code):
return self.env['rsbu.account'].sudo().search([
('company_id', '=', self.company_id.id),
('code', '=', code)], limit=1)
def _role_account(self, role):
rec = self.env['rsbu.operation.template.role'].sudo().search([
('role', '=', role),
('company_id', 'in', (False, self.company_id.id)),
], limit=1)
return rec.account_id
def _new_move(self, ref, lines):
"""Создать и провести регламентную rsbu.move; вернуть её."""
self.ensure_one()
move = self.env['rsbu.move'].sudo().create({
'date': self.date_to,
'company_id': self.company_id.id,
'ref': ref,
'line_ids': [(0, 0, ln) for ln in lines],
})
move.action_post()
self.move_ids = [(4, move.id)]
return move
def _has_move_by_ref(self, ref):
"""Идемпотентность: уже есть регламентная проводка с таким ref."""
self.ensure_one()
return any(m.ref == ref for m in self.move_ids)
def _month_turnover(self, account):
"""Обороты Дт/Кт счёта (без потомков) за месяц закрытия."""
self.ensure_one()
return account.get_turnover(
date_from=self.date_from, date_to=self.date_to,
company_id=self.company_id.id, include_children=False)
# ------------------------------------------------------------------
# Регламент: закрытие затрат 26 → 90.08 (директ-костинг)
# ------------------------------------------------------------------
def action_close_expenses(self):
"""Списать общехозяйственные расходы (26) на 90.08 (директ-костинг).
Дебетовый оборот 26 за месяц переносится проводкой
Дт 90.08 — Кт 26; счёт 26 на конец месяца обнуляется.
"""
for rec in self:
ref = _('Закрытие счёта 26 (директ-костинг)')
if rec._has_move_by_ref(ref):
continue
acc26 = rec._acc('26')
acc9008 = rec._acc('90.08.1') or rec._acc('90.08')
if not acc26 or not acc9008:
continue
turnover = rec._month_turnover(acc26)
amount = round(turnover['debit'] - turnover['credit'], 2)
if amount <= 0:
continue
rec._new_move(ref, [
{'account_id': acc9008.id, 'debit': amount,
'name': _('Управленческие расходы')},
{'account_id': acc26.id, 'credit': amount,
'name': _('Списаны общехозяйственные расходы')},
])
return True
# ------------------------------------------------------------------
# Регламент: закрытие 90 → 99 и 91 → 99 через 90.09 / 91.09
# ------------------------------------------------------------------
def _close_result_group(self, group_code, result_sub_code, ref):
"""Закрыть результатный счёт (90/91) на 99 через итоговый субсчёт.
1. Каждый субсчёт группы (кроме итогового *.09) закрывается за месяц
встречной проводкой на итоговый субсчёт (90.09/91.09), обнуляя
свой месячный оборот.
2. Итоговый субсчёт переносится на 99.01.1: прибыль
Дт *.09 — Кт 99.01.1, убыток — обратной проводкой.
Универсально для обоих планов (коды субсчетов берутся из дерева,
а не хардкодятся: 1С 90.01.1 vs 94н 90.01).
"""
self.ensure_one()
group = self._acc(group_code)
result_sub = self._acc(result_sub_code)
profit = self._acc('99.01.1') or self._acc('99.01') or self._acc('99')
if not group or not result_sub or not profit:
return
if self._has_move_by_ref(ref):
return
# Листовые субсчета группы, кроме итогового (*.09) и самой группы.
Account = self.env['rsbu.account'].sudo()
subs = Account.search([
('id', 'child_of', group.id),
('id', '!=', group.id),
('id', '!=', result_sub.id),
('company_id', '=', self.company_id.id),
])
# Только листья (без промежуточных групп вроде 90.01 над 90.01.1).
leaf_subs = subs.filtered(lambda a: not a.child_ids)
lines = []
net = 0.0 # + прибыль (Кт группы больше), − убыток
for sub in leaf_subs:
t = self._month_turnover(sub)
debit = round(t['debit'], 2)
credit = round(t['credit'], 2)
# Закрываем субсчёт встречной суммой на итоговый субсчёт.
diff = round(credit - debit, 2)
if diff > 0:
# У субсчёта кредитовый оборот (выручка) → Дт суб — Кт *.09.
lines.append({'account_id': sub.id, 'debit': diff,
'name': _('Закрытие субсчёта')})
lines.append({'account_id': result_sub.id, 'credit': diff,
'name': _('Финрезультат')})
net += diff
elif diff < 0:
# Дебетовый оборот (расходы/НДС) → Дт *.09 — Кт суб.
amt = -diff
lines.append({'account_id': result_sub.id, 'debit': amt,
'name': _('Финрезультат')})
lines.append({'account_id': sub.id, 'credit': amt,
'name': _('Закрытие субсчёта')})
net -= amt
# Перенос итогового субсчёта на 99.01.1.
net = round(net, 2)
if net > 0: # прибыль: Дт *.09 — Кт 99.01.1
lines.append({'account_id': result_sub.id, 'debit': net,
'name': _('Прибыль от продаж')})
lines.append({'account_id': profit.id, 'credit': net,
'name': _('Прибыль отнесена на 99')})
elif net < 0: # убыток: Дт 99.01.1 — Кт *.09
amt = -net
lines.append({'account_id': profit.id, 'debit': amt,
'name': _('Убыток отнесён на 99')})
lines.append({'account_id': result_sub.id, 'credit': amt,
'name': _('Убыток от продаж')})
if lines:
self._new_move(ref, lines)
def action_close_pl(self):
"""Закрыть счета продаж (90) и прочих доходов/расходов (91) на 99."""
for rec in self:
rec._close_result_group('90', '90.09', _('Закрытие счёта 90'))
rec._close_result_group('91', '91.09', _('Закрытие счёта 91'))
return True
# ------------------------------------------------------------------
# Регламент: реформация баланса (только декабрь)
# ------------------------------------------------------------------
def action_reform_balance(self):
"""Реформация баланса (закрытие года): сальдо 99 → 84.
Выполняется только для декабря. Субсчета 90/91 к этому моменту
обнулены ежемесячным закрытием (Этап 4); переносим итоговое сальдо
99 (накопленную прибыль/убыток года) на 84.
"""
for rec in self:
if not rec._is_year_end():
continue
ref = _('Реформация баланса (99 → 84)')
if rec._has_move_by_ref(ref):
continue
acc99 = rec._acc('99')
acc84 = rec._acc('84')
if not acc99 or not acc84:
continue
bal = acc99.get_balance(
date_to=rec.date_to, company_id=rec.company_id.id,
include_children=True)
amount = round(bal['balance'], 2)
if amount <= 0:
continue
profit = rec._acc('99.01.1') or rec._acc('99.01') or acc99
# Прибыль закрывается Дт 90.09/91.09 — Кт 99.01.1, поэтому
# прибыль года — КРЕДИТОВОЕ сальдо 99, убыток — ДЕБЕТОВОЕ.
if bal['side'] == 'debit':
# Дебетовое сальдо 99 → непокрытый убыток: Дт 84 — Кт 99.01.1.
rec._new_move(ref, [
{'account_id': acc84.id, 'debit': amount,
'name': _('Непокрытый убыток года')},
{'account_id': profit.id, 'credit': amount,
'name': _('Списан убыток на 84')},
])
else:
# Кредитовое сальдо 99 → прибыль: Дт 99.01.1 — Кт 84.
rec._new_move(ref, [
{'account_id': profit.id, 'debit': amount,
'name': _('Списана прибыль на 84')},
{'account_id': acc84.id, 'credit': amount,
'name': _('Нераспределённая прибыль года')},
])
return True
# ------------------------------------------------------------------
# Регламент 1: вычет авансового НДС по зачтённым облагаемым авансам
# ------------------------------------------------------------------
def _vat_deduction_lines(self):
"""Строки вычета Дт 68.02 — Кт 76.АВ по каждому авансу-основанию.
По каждому origin с начисленным авансовым НДС (Дт 76.АВ) вычитается
доля, пропорциональная зачтённой ОБЛАГАЕМОЙ отгрузкой части аванса
(Дт 62.02 по origin в проводке-реализации, содержащей НДС).
"""
self.ensure_one()
Line = self.env['rsbu.move.line'].sudo()
vat_advance = self._role_account('vat_advance') # 76.АВ
vat_output = self._role_account('vat_output') # 68.02/68.НДС
advance = self._role_account('advance_received') # 62.02
if not (vat_advance and vat_output and advance):
return []
dom = [('company_id', '=', self.company_id.id), ('state', '=', 'posted'),
('date', '<=', self.date_to)]
# НДС по origin (Дт 76.АВ).
data = {}
for ln in Line.search(dom + [('account_id', '=', vat_advance.id),
('debit', '>', 0),
('origin_move_id', '!=', False)]):
d = data.setdefault(ln.origin_move_id.id, {
'origin': ln.origin_move_id, 'vat': 0.0,
'advance': 0.0, 'offset': 0.0, 'partner': ln.partner_id})
d['vat'] += ln.debit
# Сумма аванса и зачёт облагаемой отгрузкой (Дт/Кт 62.02 по origin).
for ln in Line.search(dom + [('account_id', '=', advance.id),
('origin_move_id', 'in',
list(data.keys()))]):
d = data.get(ln.origin_move_id.id)
if not d:
continue
if ln.credit:
d['advance'] += ln.credit
if ln.debit and self._move_has_vat(ln.move_id, vat_output):
d['offset'] += ln.debit
lines = []
for d in data.values():
if d['advance'] <= 0 or d['offset'] <= 0:
continue
share = min(1.0, d['offset'] / d['advance'])
vat_deduct = round(d['vat'] * share, 2)
if vat_deduct <= 0:
continue
lines.append({
'account_id': vat_output.id, 'debit': vat_deduct,
'name': _('Вычет авансового НДС')})
lines.append({
'account_id': vat_advance.id, 'credit': vat_deduct,
'partner_id': d['partner'].id if d['partner'] else False,
'origin_move_id': d['origin'].id,
'name': _('Списан авансовый НДС')})
return lines
def _move_has_vat(self, move, vat_output):
"""Есть ли в проводке-реализации начисление НДС (Кт vat_output)."""
return any(l.account_id == vat_output and l.credit > 0
for l in move.line_ids)
def action_vat_deduction(self):
"""Сформировать проводку вычета авансового НДС (книга покупок)."""
for rec in self:
lines = rec._vat_deduction_lines()
if lines:
rec._new_move(_('Формирование записей книги покупок'), lines)
return True
# ------------------------------------------------------------------
# Регламент 2: перенос налогов на ЕНС и начисление УСН
# ------------------------------------------------------------------
def action_ens_transfer(self):
"""Перенести НДС и УСН на ЕНС (68.90) по кредитовому сальдо счетов."""
for rec in self:
ens = rec._acc('68.90')
vat = rec._role_account('vat_output') or rec._acc('68.02')
usn = rec._acc('68.12')
if not ens:
# Счёт ЕНС есть в 1С-кодировке; в академическом плане 94н
# его может не быть — перенос на ЕНС тогда пропускаем.
continue
lines = []
for tax_acc in (vat, usn):
if not tax_acc:
continue
bal = tax_acc.get_balance(
date_to=rec.date_to, company_id=rec.company_id.id)
# Кредитовое сальдо налога → переносим на ЕНС.
amount = bal['balance'] if bal['side'] == 'credit' else 0.0
amount = round(amount, 2)
if amount <= 0:
continue
lines.append({
'account_id': tax_acc.id, 'debit': amount,
'name': _('Перенос на ЕНС')})
lines.append({
'account_id': ens.id, 'credit': amount,
'name': _('Перенос на ЕНС: %s') % tax_acc.code})
if lines:
rec._new_move(_('Перенос налогов на ЕНС'), lines)
return True
# ------------------------------------------------------------------
# Регламент 3: начисление налога УСН (Дт 99.01.1 — Кт 68.12)
# ------------------------------------------------------------------
usn_tax_amount = fields.Monetary(
string='Налог УСН к начислению', currency_field='currency_id',
help='Сумма налога УСН за период. Рассчитывается нарастающим итогом '
'с начала года кнопкой «Рассчитать налог УСН» (база КУДиР × '
'ставка − вычет взносов − авансы прошлых периодов) либо '
'вводится вручную. Начисляется проводкой Дт 99.01.1 — Кт 68.12.')
usn_tax_base = fields.Monetary(
string='База УСН (нараст.)', currency_field='currency_id',
help='Налоговая база УСН нарастающим итогом с начала года: доход '
'раздела I КУДиР (НДС уже исключён).')
usn_reduction = fields.Monetary(
string='Вычет (взносы, пособия)', currency_field='currency_id',
help='Сумма страховых взносов и пособий, уменьшающая налог УСН '
'«Доходы» (в пределах лимита). Заполняется расчётом из раздела '
'IV КУДиР; при необходимости корректируется вручную.')
usn_advance_paid = fields.Monetary(
string='Авансы УСН прошлых периодов', currency_field='currency_id',
help='Ранее начисленный налог УСН с начала года (обороты Дт 99.01.1 '
'до начала периода), вычитается из налога нарастающим итогом.')
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
def _year_start(self):
"""Первое января года периода (для расчёта нарастающим итогом)."""
self.ensure_one()
return self.date_to.replace(month=1, day=1) if self.date_to else False
def _usn_income_base(self, date_from, date_to):
"""Доход раздела I КУДиР за интервал (НДС уже исключён)."""
self.ensure_one()
Kudir = self.env['rsbu.kudir.line'].sudo()
Kudir.recompute_period(date_from, date_to, self.company_id)
lines = Kudir.search([
('company_id', '=', self.company_id.id),
('section', '=', 'section_1'),
('date', '>=', date_from), ('date', '<=', date_to),
])
return round(sum(lines.mapped('income_amount'))
- sum(lines.mapped('expense_amount')), 2)
def _usn_reduction_amount(self, date_from, date_to):
"""Вычет по УСН «Доходы»: взносы/пособия (раздел IV КУДиР) за период."""
self.ensure_one()
Kudir = self.env['rsbu.kudir.line'].sudo()
lines = Kudir.search([
('company_id', '=', self.company_id.id),
('section', '=', 'section_4'),
('date', '>=', date_from), ('date', '<=', date_to),
])
return round(sum(lines.mapped('expense_amount')), 2)
def _usn_advance_paid_amount(self, year_start):
"""Ранее начисленный УСН с начала года до начала периода (Дт 99.01.1)."""
self.ensure_one()
if not self.date_from:
return 0.0
profit = self._acc('99.01.1')
if not profit:
return 0.0
Line = self.env['rsbu.move.line'].sudo()
lines = Line.search([
('account_id', '=', profit.id), ('debit', '>', 0),
('company_id', '=', self.company_id.id),
('date', '>=', year_start), ('date', '<', self.date_from),
])
return round(sum(lines.mapped('debit')), 2)
def compute_usn_tax(self):
"""Рассчитать налог УСН нарастающим итогом с начала года.
Алгоритм (УСН «Доходы» + НДС 5%, п.1 ст.248 НК; методика 1С):
1. База = доход раздела I КУДиР с начала года (НДС уже исключён);
2. Исчислено = база × ставка (rsbu_usn_rate);
3. Уменьшение на взносы/пособия (раздел IV КУДиР) — в пределах
лимита rsbu_usn_reduction_limit % от исчисленного (50% для
организаций/ИП с работниками, 100% для ИП без работников);
4. Минус ранее начисленный УСН с начала года (авансы прошлых
периодов).
Результат — налог к начислению за текущий период (Дт 99.01.1 —
Кт 68.12). Взнос/пособия и авансы показываются отдельными полями
для прозрачности; их можно скорректировать вручную перед
начислением.
"""
for rec in self:
company = rec.company_id
year_start = rec._year_start()
if not year_start:
continue
base = rec._usn_income_base(year_start, rec.date_to)
rate = (company.rsbu_usn_rate or 0.0) / 100.0
computed = round(base * rate, 2)
reduction = rec._usn_reduction_amount(year_start, rec.date_to)
# Лимит уменьшения (для «Доходы»): не более N% исчисленного.
limit_pct = (company.rsbu_usn_reduction_limit or 0.0) / 100.0
if company.rsbu_usn_mode == 'usn_income' and limit_pct:
reduction = min(reduction, round(computed * limit_pct, 2))
advance_paid = rec._usn_advance_paid_amount(year_start)
tax = round(computed - reduction - advance_paid, 2)
rec.usn_tax_base = base
rec.usn_reduction = reduction
rec.usn_advance_paid = advance_paid
rec.usn_tax_amount = max(tax, 0.0)
return True
def action_accrue_usn(self):
"""Начислить налог УСН: Дт 99.01.1 — Кт 68.12 на usn_tax_amount."""
for rec in self:
amount = round(rec.usn_tax_amount or 0.0, 2)
if amount <= 0:
continue
if rec._has_move_by_ref(_('Начисление налога УСН')):
continue
profit = rec._acc('99.01.1') or rec._acc('99.01') or rec._acc('99')
usn = rec._acc('68.12')
if not (profit and usn):
raise UserError(_('Не найдены счета 99.01.1 или 68.12.'))
rec._new_move(_('Начисление налога УСН'), [
{'account_id': profit.id, 'debit': amount,
'name': _('Начислен налог УСН')},
{'account_id': usn.id, 'credit': amount,
'name': _('Налог УСН к уплате')},
])
return True
def action_close(self):
"""Полное закрытие месяца в порядке 1С.
Цепочка (по методике 1С для УСН+НДС):
1. Закрытие затрат 26 → 90.08 (директ-костинг);
2. Налоги (только на конец квартала): расчёт и начисление УСН,
вычет авансового НДС, перенос НДС/УСН на ЕНС;
3. Финансовый результат: закрытие 90 → 99 и 91 → 99;
4. Реформация баланса (только декабрь): сальдо 99 → 84.
"""
for rec in self:
rec._check_can_close()
# 1. Затраты.
rec.action_close_expenses()
# 2. Налоги — на конец квартала (март/июнь/сентябрь/декабрь).
if rec._is_quarter_end():
rec.compute_usn_tax()
if rec.usn_tax_amount:
rec.action_accrue_usn()
rec.action_vat_deduction()
rec.action_ens_transfer()
# 3. Финрезультат.
rec.action_close_pl()
# 4. Реформация баланса (декабрь).
if rec._is_year_end():
rec.action_reform_balance()
rec.state = 'done'
return True
def _check_can_close(self):
"""Проверки перед закрытием месяца (даты, завершённость, порядок).
1. Границы периода должны соответствовать ровно календарному месяцу
period_month (защита от рассинхрона date_from/date_to).
2. Нельзя закрывать месяц раньше его окончания (незавершённый или
будущий месяц).
3. Нельзя закрывать месяц раньше даты начала учёта РСБУ.
4. Последовательность (как в 1С): предыдущий месяц должен быть
закрыт — но только если он не раньше месяца начала учёта. Месяц
начала учёта закрывается свободно (первый в цепочке).
5. Повторно закрывать уже закрытый месяц нельзя.
"""
import calendar
self.ensure_one()
if self.state == 'done':
raise UserError(_('Месяц уже закрыт. Сначала переоткройте.'))
if not self.period_month:
raise UserError(_('Не задан месяц закрытия.'))
# 1. Согласованность границ с месяцем.
first = self.period_month.replace(day=1)
last_day = calendar.monthrange(first.year, first.month)[1]
expected_to = first.replace(day=last_day)
if self.date_from != first or self.date_to != expected_to:
raise UserError(_(
'Период закрытия должен точно соответствовать календарному '
'месяцу %(m)s %(y)s (с %(f)s по %(t)s). Проверьте поле '
'«Месяц закрытия».',
m=self._MONTH_NAMES[first.month], y=first.year,
f=first, t=expected_to))
# 2. Месяц должен быть завершён.
if expected_to >= fields.Date.context_today(self):
raise UserError(_(
'Нельзя закрывать незавершённый или будущий месяц: закрытие '
'возможно только после окончания месяца (%s).') % expected_to)
# 3. Не раньше месяца начала учёта.
start = self.company_id.rsbu_accounting_start
start_month = start.replace(day=1) if start else False
if start_month and first < start_month:
raise UserError(_(
'Учёт РСБУ ведётся с %(s)s. Месяцы до месяца начала учёта '
'закрывать не требуется.', s=start))
# 4. Последовательность — только если предыдущий месяц не раньше
# месяца начала учёта. Месяц начала учёта — первый в цепочке.
if not (start_month and first <= start_month):
prev_last = first - timedelta(days=1)
prev_first = prev_last.replace(day=1)
# Требуем закрытым предыдущий месяц, если он в периоде учёта
# (>= месяца начала) и по нему есть проводки.
in_accounting = not start_month or prev_first >= start_month
Line = self.env['rsbu.move.line'].sudo()
has_prev_postings = bool(Line.search_count([
('company_id', '=', self.company_id.id),
('state', '=', 'posted'),
('date', '>=', prev_first),
('date', '<=', prev_last),
]))
if in_accounting and has_prev_postings:
prev_closed = self.search_count([
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '=', prev_first),
('date_to', '=', prev_last),
])
if not prev_closed:
raise UserError(_(
'Сначала закройте предыдущий месяц (%(m)s %(y)s): '
'закрытие выполняется последовательно.',
m=self._MONTH_NAMES[prev_first.month],
y=prev_first.year))
return True
def action_reopen(self):
"""Отменить регламентные проводки и вернуть в черновик."""
for rec in self:
moves = rec.move_ids.sudo()
posted = moves.filtered(lambda m: m.state == 'posted')
if posted:
posted.action_draft()
moves.unlink()
rec.move_ids = [(5, 0, 0)]
rec.state = 'draft'
return True

View File

@@ -0,0 +1,263 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
# Процент ставки налога строки → значение vat_rate счёта-фактуры.
_PCT_TO_VAT_RATE = [
(20.0, 'vat_20'),
(10.0, 'vat_10'),
(7.0, 'vat_7'),
(5.0, 'vat_5'),
]
class RsbuRealization(models.Model):
"""РСБУ — Документ реализации.
Первичный документ отгрузки (эволюция отгрузочного счёта-фактуры).
Создаётся для ЛЮБОЙ отгрузки — облагаемой НДС и «Без НДС». Несёт
передаточную часть УПД (upd_*) и является источником печати и XML УПД.
При наличии НДС порождает подчинённый отгрузочный счёт-фактуру
(rsbu.invoice, invoice_id).
"""
_name = 'rsbu.realization'
_description = 'РСБУ — Документ реализации'
_order = 'date desc, name desc, id desc'
_check_company_auto = True
name = fields.Char(string='Номер документа реализации', default='/',
copy=False, index=True)
date = fields.Date(string='Дата реализации', required=True, index=True,
default=fields.Date.context_today)
partner_id = fields.Many2one('res.partner', string='Контрагент',
required=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
currency_id = fields.Many2one(
'res.currency', string='Валюта', compute='_compute_currency_id',
store=True)
# Связь с источником — инвойс Odoo (как у отгрузочного СФ).
source_model = fields.Char(string='Модель источника', copy=False)
source_res_id = fields.Integer(string='ID источника', copy=False)
state = fields.Selection(
selection=[('draft', 'Черновик'), ('confirmed', 'Подтверждён'),
('cancelled', 'Отменён')],
string='Состояние', default='draft', copy=False)
# --- Передаточная часть УПД (перенос с rsbu.invoice) ---
upd_shipper_id = fields.Many2one(
'res.partner', string='Грузоотправитель',
help='Грузоотправитель для УПД (ГрузОт/ГрузОтпр). Если не задан — '
'совпадает с продавцом («он же»).')
upd_consignee_id = fields.Many2one(
'res.partner', string='Грузополучатель',
help='Грузополучатель для УПД (ГрузПолуч). Если не задан — '
'совпадает с покупателем.')
upd_transferred_by_id = fields.Many2one(
'res.users', string='Передал (кладовщик)',
help='Лицо, ответственное за передачу товаров/услуг со стороны '
'продавца (СвПродПер/СвЛицПер/РабОргПрод в УПД).')
upd_received_by_id = fields.Many2one(
'res.users', string='Принял груз',
help='Лицо, принявшее груз со стороны покупателя. Отражается в '
'документе о приёмке (файл покупателя ON_NSCHFDOPPOK).')
upd_transport = fields.Char(
string='Данные о транспортировке',
help='Сведения о транспортировке и грузе (Тран/СвТран в УПД).')
upd_basis = fields.Char(
string='Основание передачи',
help='Основание (договор, накладная и т.п.) — ОснПер в УПД. Если '
'не задано — берётся договор/номер документа-источника.')
upd_operation_content = fields.Char(
string='Содержание операции', default='Товары/услуги переданы',
help='Содержание операции передачи (СвПер/СодОпер в УПД).')
# Связь 1 — 0..1 с отгрузочным СФ (порождённым при наличии НДС).
invoice_id = fields.Many2one(
'rsbu.invoice', string='Счёт-фактура (при НДС)', copy=False,
ondelete='set null')
# Налоговая сводка строк (питает суммовые итоги УПД).
amount_untaxed = fields.Monetary(string='База (без НДС)',
currency_field='currency_id')
amount_tax = fields.Monetary(string='НДС всего',
currency_field='currency_id')
upd_function = fields.Selection(
selection=[('1', 'СЧФДОП'), ('2', 'ДОП')],
string='Функция УПД', compute='_compute_upd_function')
@api.depends('company_id')
def _compute_currency_id(self):
for rec in self:
rec.currency_id = rec.company_id.currency_id
@api.depends('invoice_id')
def _compute_upd_function(self):
for rec in self:
rec.upd_function = '1' if rec.invoice_id else '2'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', '/') == '/':
seq = self.env['ir.sequence'].with_company(
vals.get('company_id') or self.env.company.id)
vals['name'] = seq.next_by_code('rsbu.realization') or '/'
return super().create(vals_list)
# ------------------------------------------------------------------
# Связь с инвойсом-источником (для товарных позиций УПД, Вариант B)
# ------------------------------------------------------------------
def _source_invoice(self):
"""Вернуть документ-источник (account.move), если задан.
Товарные позиции УПД (наименование/кол-во/ОКЕИ/ГТИН) берутся из
инвойса Odoo, а финансовые показатели — из РСБУ-слоя (Вариант B).
"""
self.ensure_one()
if self.source_model == 'account.move' and self.source_res_id:
move = self.env['account.move'].browse(self.source_res_id)
return move if move.exists() else self.env['account.move']
return self.env['account.move']
# ------------------------------------------------------------------
# Наличие НДС, выбор ставки, порождение отгрузочного СФ
# ------------------------------------------------------------------
@api.model
def _has_vat(self, move):
"""True ⇔ в документе есть ФАКТИЧЕСКИЙ выходной НДС.
Единственный источник истины — вычисленная compute_all сумма налога
по строкам источника. Устойчиво к price_include (спецставки УСН
«в том числе» 5/7% опознаются как облагаемые). НЕ использовать
price_total != price_subtotal и НЕ кредит vat_output как детектор.
"""
for line in move.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')):
if not line.tax_ids:
continue
res = line.tax_ids.compute_all(
line.price_unit, quantity=line.quantity,
currency=line.currency_id, product=line.product_id,
partner=line.partner_id)
if any(t.get('amount') for t in res['taxes']):
return True
return False
@api.model
def _upd_vat_rate(self, move):
"""Ставка vat_rate СФ по ФАКТИЧЕСКИМ налогам строк источника.
Отображает процент налога на селектор rsbu.invoice.vat_rate. При
смешанных ставках берётся максимальная ненулевая (консервативно для
отчётности, Q7). Нет ненулевых налогов → vat_exempt.
"""
percents = []
for line in move.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')):
for tax in line.tax_ids:
if tax.amount:
percents.append(tax.amount)
if not percents:
return 'vat_exempt'
top = max(percents)
for pct, rate in _PCT_TO_VAT_RATE:
if abs(top - pct) < 0.01:
return rate
return 'vat_exempt'
def _upd_advance_offset(self):
"""Данные зачёта аванса из РСБУ-проводки реализации.
Возвращает (origin_move, offset_amount): проводка-аванс, зачтённая
этой отгрузкой (первое основание), и общая сумма зачёта. Проводку
читаем через источник — у rsbu.realization нет rsbu_move_id (Q6).
"""
self.ensure_one()
move = self._source_invoice().rsbu_move_id
if not move:
return self.env['rsbu.move'], 0.0
advance_acc = self.env['rsbu.invoice'].sudo(
)._role_account_for_company('advance_received', self.company_id)
if not advance_acc:
return self.env['rsbu.move'], 0.0
offset_lines = move.line_ids.filtered(
lambda l: l.account_id == advance_acc and l.debit > 0
and l.origin_move_id)
origin = offset_lines[:1].origin_move_id
total = sum(offset_lines.mapped('debit'))
return origin, total
def _upd_vat_amount(self):
"""Сумма НДС реализации из РСБУ-проводки (Кт счёта vat_output).
Фолбэк — amount_tax реализации, если проводки нет. vat_output —
ИСТОЧНИК СУММЫ, не детектор наличия НДС (детектор — _has_vat).
"""
self.ensure_one()
move = self._source_invoice().rsbu_move_id
vat_accounts = self.env['rsbu.vat.book.line'].sudo(
)._vat_output_accounts(self.company_id)
if move and vat_accounts:
credit = sum(move.line_ids.filtered(
lambda l: l.account_id in vat_accounts and l.credit > 0
).mapped('credit'))
if credit:
return credit
return abs(self.amount_tax)
def _create_shipment_invoice(self):
"""Создать подчинённый отгрузочный СФ (rsbu.invoice, shipment).
Вызывается при наличии НДС (_has_vat). Инвариант INV-1: у реализации
не более одного активного отгрузочного СФ.
"""
self.ensure_one()
if self.invoice_id and self.invoice_id.state != 'cancelled':
raise UserError(_(
'По этому документу реализации уже выписан счёт-фактура: '
'%s.') % self.invoice_id.name)
move = self._source_invoice()
origin_move, _offset = self._upd_advance_offset()
vals = {
'invoice_type': 'shipment',
'partner_id': self.partner_id.id,
'company_id': self.company_id.id,
'date': self.date or fields.Date.context_today(self),
'vat_rate': self._upd_vat_rate(move) if move else 'vat_exempt',
'base_amount': abs(self.amount_untaxed),
'vat_amount': self._upd_vat_amount(),
'source_model': 'account.move',
'source_res_id': self.source_res_id,
'realization_id': self.id,
}
if origin_move:
vals['origin_move_id'] = origin_move.id
invoice = self.env['rsbu.invoice'].sudo().create(vals)
invoice.action_confirm()
self.invoice_id = invoice
return invoice
# ------------------------------------------------------------------
# Сброс статуса закрывающего документа у инвойса-источника
# ------------------------------------------------------------------
def _upd_reset_source_move(self):
"""При отмене/удалении реализации вернуть closing_document источника
в «Не сделан»."""
Move = self.env['account.move'].sudo()
for rec in self.filtered(lambda r: r.source_model == 'account.move'
and r.source_res_id):
move = Move.browse(rec.source_res_id)
if move.exists() and move.closing_document != 'not_done':
move.closing_document = 'not_done'
def action_cancel(self):
self._upd_reset_source_move()
return self.write({'state': 'cancelled'})
def unlink(self):
self._upd_reset_source_move()
return super().unlink()

View File

@@ -0,0 +1,156 @@
from odoo import api, fields, models
# Код счёта начисленного НДС по умолчанию (кодировка 94н). Фактический счёт
# определяется по роли `vat_output` шаблонов маппинга (может быть 68.02 в
# кодировке 1С), а эта константа — фолбэк, если роль нигде не задана.
VAT_OUTPUT_CODE = '68.НДС'
# Код счёта единого налогового счёта (ЕНС). Перенос сальдо НДС на ЕНС
# (Дт 68.02 − Кт 68.90) — регламентная операция уплаты налога, а не
# начисление/вычет НДС, поэтому такие проводки в книги НДС не попадают.
ENS_ACCOUNT_CODE = '68.90'
class RsbuVatBookLine(models.Model):
"""Запись книги продаж / покупок (собирается из проводок НДС журнала)."""
_name = 'rsbu.vat.book.line'
_description = 'РСБУ — Запись книги НДС'
_order = 'date, id'
_check_company_auto = True
book = fields.Selection(
selection=[('sales', 'Книга продаж'),
('purchases', 'Книга покупок')],
string='Книга', required=True, index=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)
move_id = fields.Many2one('rsbu.move', string='Операция',
ondelete='cascade')
move_line_id = fields.Many2one('rsbu.move.line', string='Строка журнала',
ondelete='cascade')
invoice_id = fields.Many2one('rsbu.invoice', string='Счёт-фактура')
partner_id = fields.Many2one('res.partner', string='Контрагент')
base_amount = fields.Monetary(string='База', currency_field='currency_id')
vat_amount = fields.Monetary(string='Сумма НДС',
currency_field='currency_id')
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 _vat_output_accounts(self, company):
"""Счета начисленного НДС для компании (по роли vat_output).
Определяем счёт(а) НДС по роли `vat_output` в шаблонах маппинга —
это работает независимо от кодировки (68.НДС в академическом плане
либо 68.02 в кодировке 1С). Фолбэк — счёт с кодом VAT_OUTPUT_CODE.
"""
Role = self.env['rsbu.operation.template.role'].sudo()
roles = Role.search([
('role', '=', 'vat_output'),
('company_id', 'in', (False, company.id)),
])
accounts = roles.mapped('account_id')
if not accounts:
accounts = self.env['rsbu.account'].sudo().search([
('code', '=', VAT_OUTPUT_CODE),
('company_id', '=', company.id)], limit=1)
return accounts
@api.model
def _ens_accounts(self, company):
"""Счёт(а) единого налогового счёта (ЕНС) для компании (код 68.90)."""
return self.env['rsbu.account'].sudo().search([
('code', '=', ENS_ACCOUNT_CODE),
('company_id', '=', company.id)])
@api.model
def _is_ens_transfer(self, move, vat_accounts, ens_accounts):
"""Проводка — перенос НДС на ЕНС (не операция начисления/вычета).
Признак: в проводке одновременно есть строка счёта НДС (vat_output)
и строка счёта ЕНС (68.90). Такой перенос сальдо расчётов по НДС на
единый налоговый счёт не является ни начислением (книга продаж), ни
вычетом (книга покупок) — в книги НДС не попадает.
"""
if not ens_accounts:
return False
accounts = move.line_ids.mapped('account_id')
return bool(accounts & vat_accounts) and bool(accounts & ens_accounts)
@api.model
def _is_vat_account(self, account, vat_accounts=None):
if vat_accounts is not None:
return account in vat_accounts
# Обратная совместимость (одиночный вызов): сверка по коду.
return account.code == VAT_OUTPUT_CODE
@api.model
def recompute_period(self, date_from, date_to, company):
"""Пересобрать книги продаж/покупок за период из проводок журнала.
Книга продаж — начисление НДС (Кт счёта vat_output);
книга покупок — вычет НДС (Дт счёта vat_output).
Счёт НДС определяется по роли (68.НДС или 68.02). Идемпотентно.
TODO(perf): при больших объёмах вынести в cron. Сейчас не делаем.
"""
self.search([
('company_id', '=', company.id),
('date', '>=', date_from),
('date', '<=', date_to),
]).unlink()
vat_accounts = self._vat_output_accounts(company)
ens_accounts = self._ens_accounts(company)
lines = self.env['rsbu.move.line'].search([
('company_id', '=', company.id),
('state', '=', 'posted'),
('date', '>=', date_from),
('date', '<=', date_to),
])
vals_list = []
for line in lines:
if not self._is_vat_account(line.account_id, vat_accounts):
continue
# Перенос сальдо НДС на ЕНС (Дт 68.02 − Кт 68.90) — регламентная
# уплата налога, не начисление/вычет НДС. В книги не включаем.
if self._is_ens_transfer(line.move_id, vat_accounts, ens_accounts):
continue
if line.credit: # начисление -> книга продаж
vals_list.append(self._line_vals(line, 'sales',
line.credit))
elif line.debit: # вычет -> книга покупок
vals_list.append(self._line_vals(line, 'purchases',
line.debit))
if vals_list:
return self.create(vals_list)
return self.browse()
@api.model
def _line_vals(self, line, book, vat_amount):
return {
'book': book,
'date': line.date,
'company_id': line.company_id.id,
'move_id': line.move_id.id,
'move_line_id': line.id,
'partner_id': line.partner_id.id or False,
'vat_amount': vat_amount,
}

View File

@@ -0,0 +1,87 @@
from odoo import api, fields, models
class RsbuVatSummary(models.Model):
"""Регистр промежуточных итогов НДС за квартал."""
_name = 'rsbu.vat.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)
tax_base = fields.Monetary(string='Налоговая база',
currency_field='currency_id')
vat_charged = fields.Monetary(string='НДС начислен',
currency_field='currency_id')
vat_deducted = fields.Monetary(string='Вычеты',
currency_field='currency_id')
vat_payable = fields.Monetary(string='НДС к уплате',
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
# Доля НДС в сумме по ставочному режиму (для восстановления базы из НДС).
_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
@api.model
def recompute_period(self, date_from, date_to, company):
"""Пересчитать итоги НДС по кварталам периода из книг.
Налоговая база начисления (tax_base) восстанавливается из суммы
начисленного НДС по расчётной ставке режима компании (5/105 или
7/107): база = НДС × (1/доля − 1). Для единой спецставки УСН это
точно; при смешанных ставках потребуется база из строк книги
(base_amount) — TODO.
"""
BookLine = self.env['rsbu.vat.book.line']
book_lines = BookLine.search([
('company_id', '=', company.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
by_period = {}
for bl in book_lines:
data = by_period.setdefault(
bl.period_key,
{'charged': 0.0, 'deducted': 0.0, 'base': 0.0})
if bl.book == 'sales':
data['charged'] += bl.vat_amount
# База из строки, если задана; иначе восстановим ниже по ставке.
data['base'] += bl.base_amount
else:
data['deducted'] += bl.vat_amount
fraction = self._VAT_FRACTION.get(company.rsbu_vat_mode, 0.0)
result = self.browse()
for period_key, data in by_period.items():
tax_base = data['base']
# Если база в строках не заполнена — восстановить из НДС по ставке.
if not tax_base and fraction and data['charged']:
# база = НДС / доля − НДС = НДС × (1/доля − 1)
tax_base = round(
data['charged'] * (1.0 / fraction - 1.0), 2)
existing = self.search([
('company_id', '=', company.id),
('period_key', '=', period_key)], limit=1)
vals = {
'company_id': company.id,
'period_key': period_key,
'tax_base': tax_base,
'vat_charged': data['charged'],
'vat_deducted': data['deducted'],
'vat_payable': data['charged'] - data['deducted'],
}
if existing:
existing.write(vals)
result |= existing
else:
result |= self.create(vals)
return result