Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
8
rsbu_vat/models/__init__.py
Normal file
8
rsbu_vat/models/__init__.py
Normal 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
|
||||
76
rsbu_vat/models/account_payment.py
Normal file
76
rsbu_vat/models/account_payment.py
Normal 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()
|
||||
13
rsbu_vat/models/res_company.py
Normal file
13
rsbu_vat/models/res_company.py
Normal 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')
|
||||
9
rsbu_vat/models/res_config_settings.py
Normal file
9
rsbu_vat/models/res_config_settings.py
Normal 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='Режим НДС на УСН')
|
||||
175
rsbu_vat/models/rsbu_invoice.py
Normal file
175
rsbu_vat/models/rsbu_invoice.py
Normal 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']
|
||||
645
rsbu_vat/models/rsbu_period_close.py
Normal file
645
rsbu_vat/models/rsbu_period_close.py
Normal 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
|
||||
263
rsbu_vat/models/rsbu_realization.py
Normal file
263
rsbu_vat/models/rsbu_realization.py
Normal 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()
|
||||
156
rsbu_vat/models/rsbu_vat_book_line.py
Normal file
156
rsbu_vat/models/rsbu_vat_book_line.py
Normal 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,
|
||||
}
|
||||
87
rsbu_vat/models/rsbu_vat_summary.py
Normal file
87
rsbu_vat/models/rsbu_vat_summary.py
Normal 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
|
||||
Reference in New Issue
Block a user