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

57
rsbu_vat/README.rst Normal file
View File

@@ -0,0 +1,57 @@
======================
РСБУ — Учёт НДС на УСН
======================
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
|badge1| |badge2|
Учёт НДС для плательщиков УСН, которые обязаны исчислять налог. НДС
учитывается на основании счетов-фактур и проводок журнала РСБУ.
**Содержание**
.. contents::
:local:
Возможности
===========
* Настройка ставки НДС: освобождён, 5% или 7%
* Счета-фактуры: авансовые, отгрузочные, корректировочные
* Книга продаж и книга покупок формируются из проводок НДС
* Свод итогов по НДС и расчёт суммы декларации за период
* Пересчёт данных за выбранный период
Настройка
=========
#. Перейдите в *Настройки* и укажите применяемую ставку НДС.
Использование
=============
#. Оформляйте счета-фактуры к операциям реализации и поступления.
#. Книга продаж и книга покупок, а также итоговые суммы для декларации
рассчитываются автоматически из проводок НДС.
#. Откройте *РСБУ > НДС*, чтобы просмотреть книги и свод по НДС.
#. При необходимости пересчитайте данные за нужный период через
соответствующий мастер.
Авторы
======
* MK.Lab, RuOdoo
Модуль сопровождается MK.Lab, RuOdoo.
.. image:: https://ruodoo.ru/logo.png
:alt: MK.Lab, RuOdoo
:target: https://ruodoo.ru
Модуль является частью РСБУ-слоя проекта РуОду.

2
rsbu_vat/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from . import models
from . import wizards

50
rsbu_vat/__manifest__.py Normal file
View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
{
'name': 'РСБУ — Учёт НДС на УСН',
'version': '19.0.2026.10.01',
'summary': 'Счета-фактуры, книги продаж/покупок, декларация НДС',
'description': """
РСБУ — Учёт НДС на УСН
======================
Учёт НДС для плательщиков УСН, которые обязаны исчислять налог.
НДС учитывается на основании счетов-фактур и проводок журнала РСБУ.
Возможности:
------------
* Настройка ставки НДС: освобождён, 5% или 7%.
* Счета-фактуры: авансовые, отгрузочные и корректировочные.
* Книга продаж и книга покупок формируются из проводок НДС.
* Свод итогов по НДС и расчёт суммы декларации за период.
* Пересчёт данных за выбранный период.
Как пользоваться:
-----------------
1. Укажите ставку НДС в настройках.
2. Оформляйте счета-фактуры к операциям реализации и поступления.
3. Книги продаж и покупок и итоговые суммы для декларации
рассчитываются автоматически; при необходимости пересчитайте
их за нужный период.
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Accounting/Localizations',
'depends': ['rsbu_kudir'],
'data': [
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'wizards/rsbu_vat_recompute_wizard_views.xml',
'wizards/rsbu_vat_declaration_wizard_views.xml',
'views/res_config_settings_views.xml',
'views/rsbu_invoice_views.xml',
'views/rsbu_realization_views.xml',
'views/rsbu_vat_book_views.xml',
'views/rsbu_vat_summary_views.xml',
'views/rsbu_period_close_views.xml',
'views/rsbu_menus.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_rsbu_invoice" model="ir.sequence">
<field name="name">РСБУ Счёт-фактура</field>
<field name="code">rsbu.invoice</field>
<field name="prefix">СФ/%(range_year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_rsbu_realization" model="ir.sequence">
<field name="name">РСБУ Документ реализации</field>
<field name="code">rsbu.realization</field>
<field name="prefix">РЕАЛ/%(range_year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""Post-migration: построить rsbu.realization из снимка отгрузочных СФ.
Для каждой строки _mig_upd_snapshot (= бывший отгрузочный СФ) создаёт документ
реализации через ORM (чтобы отработал create-хук номера реализации), переносит
передаточные upd_* поля, восстанавливает amount_untaxed/amount_tax из
исходного account.move (фолбэк — base_amount/vat_amount СФ) и связывает
реализацию с СФ двусторонне. Идемпотентно: NO-OP при отсутствии снимка;
реализации создаются только для СФ с realization_id IS NULL.
"""
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
# NO-OP guard: нет снимка — нечего мигрировать (повторный прогон).
cr.execute("SELECT to_regclass('_mig_upd_snapshot')")
if cr.fetchone()[0] is None:
_logger.info('rsbu_vat post-migrate 19.0.2026.10.01: no snapshot, '
'nothing to migrate')
return
cr.execute("""
SELECT invoice_id, source_model, source_res_id, origin_move_id,
upd_shipper_id, upd_consignee_id, upd_transferred_by_id,
upd_received_by_id, upd_transport, upd_basis,
upd_operation_content,
partner_id, company_id, date, base_amount, vat_amount,
vat_rate, state
FROM _mig_upd_snapshot
""")
rows = cr.dictfetchall()
env = api.Environment(cr, SUPERUSER_ID, {})
Realization = env['rsbu.realization']
Invoice = env['rsbu.invoice']
Move = env['account.move']
created = 0
for row in rows:
invoice = Invoice.browse(row['invoice_id'])
if not invoice.exists() or invoice.realization_id:
# Нет СФ или уже связан — не плодим дубликаты.
continue
real = Realization.create({
'partner_id': row['partner_id'],
'company_id': row['company_id'],
'date': row['date'],
'source_model': row['source_model'],
'source_res_id': row['source_res_id'],
'state': row['state'],
'upd_shipper_id': row['upd_shipper_id'],
'upd_consignee_id': row['upd_consignee_id'],
'upd_transferred_by_id': row['upd_transferred_by_id'],
'upd_received_by_id': row['upd_received_by_id'],
'upd_transport': row['upd_transport'],
'upd_basis': row['upd_basis'],
'upd_operation_content': row['upd_operation_content'],
})
src = Move.browse(row['source_res_id']) if row['source_res_id'] else Move
real.write({
'amount_untaxed': src.amount_untaxed if src.exists()
else row['base_amount'],
'amount_tax': src.amount_tax if src.exists() else row['vat_amount'],
})
# Двусторонняя связь реализация <-> СФ.
invoice.realization_id = real.id
real.invoice_id = invoice.id
created += 1
cr.execute("DROP TABLE IF EXISTS _mig_upd_snapshot")
_logger.info('rsbu_vat post-migrate 19.0.2026.10.01 ran: %s realizations '
'created', created)

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Pre-migration для разделения rsbu.invoice на реализацию + СФ.
Снимает снимок передаточных полей (upd_*) отгрузочных СФ ДО того, как новая
схема снимет эти колонки с rsbu_invoice. Снимок переживёт загрузку новой
модели и используется post-скриптом для построения rsbu.realization.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
# Снимок строится ТОЛЬКО если колонки upd_* ещё существуют (защита от
# повторного прогона pre-скрипта после снятия колонок).
cr.execute("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'rsbu_invoice'
AND column_name = 'upd_shipper_id') THEN
CREATE TABLE IF NOT EXISTS _mig_upd_snapshot AS
SELECT id AS invoice_id, source_model, source_res_id,
origin_move_id,
upd_shipper_id, upd_consignee_id, upd_transferred_by_id,
upd_received_by_id, upd_transport, upd_basis,
upd_operation_content,
partner_id, company_id, date, base_amount, vat_amount,
vat_rate, state
FROM rsbu_invoice
WHERE invoice_type = 'shipment';
END IF;
END $$;
""")
_logger.info('rsbu_vat pre-migrate 19.0.2026.10.01 ran')

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

View File

@@ -0,0 +1,13 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rsbu_invoice_user,rsbu.invoice user,model_rsbu_invoice,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_invoice_mgr,rsbu.invoice manager,model_rsbu_invoice,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
access_rsbu_realization_user,rsbu.realization user,model_rsbu_realization,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_realization_mgr,rsbu.realization manager,model_rsbu_realization,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
access_rsbu_vat_book_user,rsbu.vat.book.line user,model_rsbu_vat_book_line,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_vat_book_mgr,rsbu.vat.book.line manager,model_rsbu_vat_book_line,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
access_rsbu_vat_summary_user,rsbu.vat.summary user,model_rsbu_vat_summary,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_vat_summary_mgr,rsbu.vat.summary manager,model_rsbu_vat_summary,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
access_rsbu_vat_recompute_wiz,rsbu.vat.recompute.wizard,model_rsbu_vat_recompute_wizard,rsbu_journal.group_rsbu_journal_user,1,1,1,1
access_rsbu_vat_declaration_wiz,rsbu.vat.declaration.wizard,model_rsbu_vat_declaration_wizard,rsbu_journal.group_rsbu_journal_user,1,1,1,1
access_rsbu_period_close_user,rsbu.period.close user,model_rsbu_period_close,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
access_rsbu_period_close_mgr,rsbu.period.close manager,model_rsbu_period_close,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_rsbu_invoice_user rsbu.invoice user model_rsbu_invoice rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
3 access_rsbu_invoice_mgr rsbu.invoice manager model_rsbu_invoice rsbu_journal.group_rsbu_journal_manager 1 1 1 1
4 access_rsbu_realization_user rsbu.realization user model_rsbu_realization rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
5 access_rsbu_realization_mgr rsbu.realization manager model_rsbu_realization rsbu_journal.group_rsbu_journal_manager 1 1 1 1
6 access_rsbu_vat_book_user rsbu.vat.book.line user model_rsbu_vat_book_line rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
7 access_rsbu_vat_book_mgr rsbu.vat.book.line manager model_rsbu_vat_book_line rsbu_journal.group_rsbu_journal_manager 1 1 1 1
8 access_rsbu_vat_summary_user rsbu.vat.summary user model_rsbu_vat_summary rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
9 access_rsbu_vat_summary_mgr rsbu.vat.summary manager model_rsbu_vat_summary rsbu_journal.group_rsbu_journal_manager 1 1 1 1
10 access_rsbu_vat_recompute_wiz rsbu.vat.recompute.wizard model_rsbu_vat_recompute_wizard rsbu_journal.group_rsbu_journal_user 1 1 1 1
11 access_rsbu_vat_declaration_wiz rsbu.vat.declaration.wizard model_rsbu_vat_declaration_wizard rsbu_journal.group_rsbu_journal_user 1 1 1 1
12 access_rsbu_period_close_user rsbu.period.close user model_rsbu_period_close rsbu_chart_of_accounts.group_rsbu_account_user 1 0 0 0
13 access_rsbu_period_close_mgr rsbu.period.close manager model_rsbu_period_close rsbu_journal.group_rsbu_journal_manager 1 1 1 1

View File

@@ -0,0 +1,2 @@
from . import test_vat
from . import test_realization

View File

@@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
"""Приёмочные тесты рефакторинга «Документ реализации» (Stage 6).
Воплощают критерии владельца (вариант 1 из эталона 1С): документ реализации
создаётся для ЛЮБОЙ отгрузки; при наличии НДС хотя бы в одной строке он
порождает подчинённый отгрузочный счёт-фактуру; из реализации печатается и
выгружается УПД (Функция СЧФДОП при наличии СФ, иначе ДОП); авансовый СФ
по-прежнему рождается из платежа; книга продаж не зависит от наличия СФ.
Эти тесты требуют и rsbu_vat (модель rsbu.realization, книги), и
rsbu_fns_reporting (кнопка action_create_realization на инвойсе, XML/DOCX
на реализации) — обе части устанавливаются в прогоне свиты.
"""
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRealizationAcceptance(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.company.rsbu_vat_mode = 'vat_5'
# Реквизиты для прохождения _upd_validate (XML/XSD).
cls.company.partner_id.write({
'inn': '7704567896', 'kpp': '770401001',
'city': 'Москва', 'street': 'ул. Тестовая, 1'})
cls.user = cls.env['res.users'].search([], limit=1)
cls.company.rsbu_signatory_id = cls.user.id
cls.user.partner_id.function = 'Директор'
cls.company.rsbu_edo_operator_code = '2BM'
cls.company.rsbu_edo_participant_id = '2BM-SENDER-1'
cls.buyer = cls.env['res.partner'].create({
'name': 'ООО Покупатель', 'inn': '7730179829',
'city': 'Москва', 'street': 'ул. Клиент, 2',
'rsbu_edo_participant_id': '2BM-BUYER-1'})
cls.product = cls.env['product.product'].create({
'name': 'Услуга А', 'type': 'service'})
cls.product.uom_id.okei = '796'
cls.journal_bank = cls.env['account.journal'].search(
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
limit=1)
# -- helpers --------------------------------------------------------
def _acc(self, code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.company.id)], limit=1)
def _tax(self, amount=5.0):
name = 'НДС %g%%' % amount
tax = self.env['account.tax'].search([
('name', '=', name), ('type_tax_use', '=', 'sale'),
('company_id', '=', self.company.id)], limit=1)
if not tax:
tax = self.env['account.tax'].create({
'name': name, 'amount': amount, 'amount_type': 'percent',
'type_tax_use': 'sale', 'company_id': self.company.id})
return tax
def _shipment_matrix(self):
tmpl = self.env['rsbu.operation.template'].create({
'code': 'SHIP_UPD_A', 'name': 'Отгрузка',
'resolver_key': 'shipment_upd', 'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'receivable',
'account_id': self._acc('62.01').id}),
(0, 0, {'role': 'revenue',
'account_id': self._acc('90.01').id}),
(0, 0, {'role': 'revenue_vat',
'account_id': self._acc('90.03').id}),
(0, 0, {'role': 'vat_output',
'account_id': self._acc('68.НДС').id}),
(0, 0, {'role': 'advance_received',
'account_id': self._acc('62.АВ').id}),
]})
self.env['rsbu.operation.matrix'].create({
'name': 'Исходящий инвойс → отгрузка (acceptance)',
'doc_kind': 'out_invoice', 'template_id': tmpl.id})
return tmpl
def _advance_matrix(self):
tmpl = self.env['rsbu.operation.template'].create({
'code': 'ADV_A', 'name': 'Аванс',
'resolver_key': 'customer_payment', 'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
(0, 0, {'role': 'receivable',
'account_id': self._acc('62.01').id}),
(0, 0, {'role': 'advance_received',
'account_id': self._acc('62.АВ').id}),
(0, 0, {'role': 'vat_advance',
'account_id': self._acc('76.АВ').id}),
(0, 0, {'role': 'vat_output',
'account_id': self._acc('68.НДС').id}),
]})
self.env['rsbu.operation.matrix'].create({
'name': 'Входящий → аванс (acceptance)',
'doc_kind': 'payment_inbound', 'template_id': tmpl.id})
return tmpl
def _post_invoice(self, price=100000.0, qty=1, tax=None):
line = {'name': 'Услуга А', 'product_id': self.product.id,
'quantity': qty, 'price_unit': price}
line['tax_ids'] = [(6, 0, tax.ids)] if tax else [(5, 0, 0)]
move = self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'company_id': self.company.id,
'invoice_line_ids': [(0, 0, line)]})
move.action_post()
return move
# -- 1. Реализация с НДС порождает отгрузочный СФ -------------------
def test_realization_with_vat_creates_sf(self):
"""Инвойс с НДС 5% → реализация + отгрузочный СФ; vat_rate = факт."""
self._shipment_matrix()
move = self._post_invoice(price=100000.0, tax=self._tax(5.0))
move.action_create_realization()
real = move.rsbu_realization_id
self.assertTrue(real)
self.assertEqual(real.state, 'confirmed')
sf = real.invoice_id
self.assertTrue(sf, 'При наличии НДС должен появиться отгрузочный СФ')
self.assertEqual(sf.invoice_type, 'shipment')
# Ставка СФ — по фактическому налогу строки (5%), не форсированная.
self.assertEqual(sf.vat_rate, 'vat_5')
self.assertEqual(real.upd_function, '1')
# -- 2. Реализация без НДС: СФ нет, УПД Функция=ДОП ------------------
def test_realization_without_vat_no_sf(self):
"""Инвойс без НДС → только реализация; XML Функция=ДОП, валиден."""
self._shipment_matrix()
move = self._post_invoice(price=100000.0, tax=None)
move.action_create_realization()
real = move.rsbu_realization_id
self.assertTrue(real)
self.assertFalse(real.invoice_id, 'Без НДС отгрузочный СФ не создаётся')
self.assertEqual(real.upd_function, '2')
xml_bytes = real._upd_build_xml(filename='T')
ok, errors = real._upd_validate_xml(xml_bytes)
self.assertTrue(ok, 'ДОП-УПД не прошёл XSD: %s'
% '; '.join(errors[:5]))
xml = xml_bytes.decode('cp1251')
self.assertIn('Функция="ДОП"', xml)
self.assertIn('<БезНДС>без НДС</БезНДС>', xml)
# -- 3. XSD валиден для обоих случаев («1» и «2») -------------------
def test_upd_xml_valid_both_cases(self):
"""УПД валиден по XSD и для СЧФДОП (с НДС), и для ДОП (без НДС)."""
self._shipment_matrix()
move_vat = self._post_invoice(price=100000.0, tax=self._tax(5.0))
move_vat.action_create_realization()
real_vat = move_vat.rsbu_realization_id
xml_vat = real_vat._upd_build_xml(filename='T')
ok_vat, err_vat = real_vat._upd_validate_xml(xml_vat)
self.assertTrue(ok_vat, 'СЧФДОП не прошёл XSD: %s'
% '; '.join(err_vat[:5]))
self.assertIn('Функция="СЧФДОП"', xml_vat.decode('cp1251'))
move_nov = self._post_invoice(price=80000.0, tax=None)
move_nov.action_create_realization()
real_nov = move_nov.rsbu_realization_id
xml_nov = real_nov._upd_build_xml(filename='T')
ok_nov, err_nov = real_nov._upd_validate_xml(xml_nov)
self.assertTrue(ok_nov, 'ДОП не прошёл XSD: %s'
% '; '.join(err_nov[:5]))
self.assertIn('Функция="ДОП"', xml_nov.decode('cp1251'))
# -- 4. Авансовый СФ рождается из платежа, без реализации -----------
def test_advance_sf_still_from_payment(self):
"""Авансовый СФ создаётся из платежа, realization_id пуст."""
self._advance_matrix()
# Облагаемая сделка того же контрагента — условие авто-создания
# авансового СФ (_rsbu_payment_is_taxable).
self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'company_id': self.company.id,
'invoice_line_ids': [(0, 0, {
'name': 'Услуга А', 'product_id': self.product.id,
'quantity': 1, 'price_unit': 100000.0,
'tax_ids': [(6, 0, self._tax(5.0).ids)]})]})
pay = self.env['account.payment'].create({
'payment_type': 'inbound', 'partner_type': 'customer',
'partner_id': self.buyer.id, 'amount': 105000.0,
'rsbu_vat_amount': 5000.0,
'journal_id': self.journal_bank.id})
pay.action_post()
advance = self.env['rsbu.invoice'].search([
('partner_id', '=', self.buyer.id),
('invoice_type', '=', 'advance'),
('company_id', '=', self.company.id)], limit=1)
self.assertTrue(advance, 'Авансовый СФ должен создаться из платежа')
self.assertEqual(advance.state, 'confirmed')
self.assertFalse(advance.realization_id,
'У авансового СФ нет документа реализации')
# -- 5. Книга продаж не зависит от наличия отгрузочного СФ ----------
def test_sales_book_independent_of_sf(self):
"""Создание/удаление отгрузочного СФ не меняет число строк книги.
Книга продаж строится на РСБУ-проводках реализации (резолвер), а не
на rsbu.invoice. Поэтому её наполнение не зависит от того, выписан ли
отгрузочный СФ.
"""
self._shipment_matrix()
move = self._post_invoice(price=100000.0, tax=self._tax(5.0))
rsbu_move = move.rsbu_move_id
self.assertTrue(rsbu_move, 'Проводка реализации должна сформироваться')
BookLine = self.env['rsbu.vat.book.line']
BookLine.recompute_period(rsbu_move.date, rsbu_move.date, self.company)
before = BookLine.search_count([
('company_id', '=', self.company.id), ('book', '=', 'sales')])
move.action_create_realization()
real = move.rsbu_realization_id
sf = real.invoice_id
self.assertTrue(sf)
BookLine.recompute_period(rsbu_move.date, rsbu_move.date, self.company)
after_create = BookLine.search_count([
('company_id', '=', self.company.id), ('book', '=', 'sales')])
self.assertEqual(after_create, before,
'Книга продаж не должна зависеть от создания СФ')
sf.unlink()
real.unlink()
BookLine.recompute_period(rsbu_move.date, rsbu_move.date, self.company)
after_delete = BookLine.search_count([
('company_id', '=', self.company.id), ('book', '=', 'sales')])
self.assertEqual(after_delete, before,
'Книга продаж не должна зависеть от удаления СФ')
# -- 6. Отображение ставки vat_rate по фактическим налогам ----------
def test_vat_rate_mapping(self):
"""_upd_vat_rate: 20/10/7/5/0 → корректный vat_rate; mixed → max."""
Realization = self.env['rsbu.realization']
cases = [
(20.0, 'vat_20'),
(10.0, 'vat_10'),
(7.0, 'vat_7'),
(5.0, 'vat_5'),
(0.0, 'vat_exempt'),
]
for pct, expected in cases:
move = self._post_invoice(price=10000.0, tax=self._tax(pct))
self.assertEqual(Realization._upd_vat_rate(move), expected,
'Ставка %g%% → %s' % (pct, expected))
# Строка без налогов → vat_exempt.
move_none = self._post_invoice(price=10000.0, tax=None)
self.assertEqual(Realization._upd_vat_rate(move_none), 'vat_exempt')
# Смешанный инвойс (0% + 20%) → максимальная ненулевая = vat_20.
move_mix = self.env['account.move'].create({
'move_type': 'out_invoice', 'partner_id': self.buyer.id,
'company_id': self.company.id,
'invoice_line_ids': [
(0, 0, {'name': 'A', 'product_id': self.product.id,
'quantity': 1, 'price_unit': 1000.0,
'tax_ids': [(6, 0, self._tax(0.0).ids)]}),
(0, 0, {'name': 'B', 'product_id': self.product.id,
'quantity': 1, 'price_unit': 1000.0,
'tax_ids': [(6, 0, self._tax(20.0).ids)]}),
]})
self.assertEqual(Realization._upd_vat_rate(move_mix), 'vat_20',
'Смешанные ставки → максимальная ненулевая')
# _has_vat: с ненулевым налогом True, без налога False.
self.assertTrue(Realization._has_vat(move_mix))
self.assertFalse(Realization._has_vat(move_none))

328
rsbu_vat/tests/test_vat.py Normal file
View File

@@ -0,0 +1,328 @@
from freezegun import freeze_time
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRsbuVat(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.partner = cls.env['res.partner'].create({'name': 'Vat Клиент'})
cls.journal_bank = cls.env['account.journal'].search(
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
limit=1)
cls.BookLine = cls.env['rsbu.vat.book.line']
cls.Summary = cls.env['rsbu.vat.summary']
def _acc(self, code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.env.company.id)], limit=1)
def _advance_template(self):
return self.env['rsbu.operation.template'].create({
'code': 'ADV_V',
'name': 'Аванс',
'resolver_key': 'advance_received',
'usn_recognition': 'income',
'role_ids': [
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
(0, 0, {'role': 'advance_received',
'account_id': self._acc('62.АВ').id}),
(0, 0, {'role': 'vat_advance',
'account_id': self._acc('76.АВ').id}),
(0, 0, {'role': 'vat_output',
'account_id': self._acc('68.НДС').id}),
],
})
def _matrix(self, template):
return self.env['rsbu.operation.matrix'].create({
'name': 'Входящий → аванс',
'doc_kind': 'payment_inbound',
'template_id': template.id,
})
def _post_advance(self, amount=1050.0, vat=50.0):
pay = self.env['account.payment'].create({
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': self.partner.id,
'amount': amount,
'rsbu_vat_amount': vat,
'journal_id': self.journal_bank.id,
})
pay.action_post()
return pay
def test_invoice_creation_and_sequence(self):
inv = self.env['rsbu.invoice'].create({
'invoice_type': 'advance',
'partner_id': self.partner.id,
'base_amount': 1000.0,
'vat_amount': 50.0,
})
self.assertNotEqual(inv.name, '/')
self.assertEqual(inv.invoice_type, 'advance')
def test_sales_book_from_advance_vat(self):
tmpl = self._advance_template()
self._matrix(tmpl)
pay = self._post_advance(amount=1050.0, vat=50.0)
move = pay.rsbu_move_id
self.BookLine.recompute_period(move.date, move.date, self.company)
sales = self.BookLine.search([
('company_id', '=', self.company.id),
('book', '=', 'sales')])
self.assertTrue(sales)
# Авансовый НДС начислен по Кт 68.НДС = 50.
self.assertEqual(sum(sales.mapped('vat_amount')), 50.0)
def test_summary_payable(self):
tmpl = self._advance_template()
self._matrix(tmpl)
pay = self._post_advance(amount=1050.0, vat=50.0)
move = pay.rsbu_move_id
self.BookLine.recompute_period(move.date, move.date, self.company)
self.Summary.recompute_period(move.date, move.date, self.company)
summary = self.Summary.search([
('company_id', '=', self.company.id)], limit=1)
self.assertTrue(summary)
# Начислено 50, вычетов нет -> к уплате 50.
self.assertEqual(summary.vat_charged, 50.0)
self.assertEqual(summary.vat_payable, 50.0)
def test_recompute_idempotent(self):
tmpl = self._advance_template()
self._matrix(tmpl)
pay = self._post_advance()
move = pay.rsbu_move_id
self.BookLine.recompute_period(move.date, move.date, self.company)
first = self.BookLine.search_count(
[('company_id', '=', self.company.id)])
self.BookLine.recompute_period(move.date, move.date, self.company)
second = self.BookLine.search_count(
[('company_id', '=', self.company.id)])
self.assertEqual(first, second)
# ------------------------------------------------------------------
# Расчёт налога УСН (rsbu.period.close.compute_usn_tax)
# ------------------------------------------------------------------
def test_usn_tax_computation(self):
"""Налог УСН = база(доход КУДиР) × ставка, база с исключённым НДС."""
self.company.rsbu_usn_mode = 'usn_income'
self.company.rsbu_usn_rate = 6.0
tmpl = self._advance_template()
self._matrix(tmpl)
# Аванс 1050 c НДС 50 → доход КУДиР 1000 (НДС исключён).
pay = self._post_advance(amount=1050.0, vat=50.0)
# Закрытие за декабрь года платежа (охватывает весь год нараст. итогом).
year = pay.rsbu_move_id.date.year
pc = self.env['rsbu.period.close'].create({
'period_month': '%s-12-01' % year,
'company_id': self.company.id})
pc.compute_usn_tax()
# База = 1000 (доход без НДС), налог = 1000 × 6% = 60 (взносов нет).
self.assertEqual(pc.usn_tax_base, 1000.0)
self.assertEqual(pc.usn_tax_amount, 60.0)
def test_advance_invoice_autocreated(self):
"""При поступлении аванса облагаемой сделки авансовый СФ создаётся
автоматически (Дт 76.АВ — Кт 68.02)."""
self.company.rsbu_vat_mode = 'vat_5'
# Облагаемая реализация того же партнёра (инвойс источник правды).
self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
'company_id': self.company.id,
'invoice_line_ids': [(0, 0, {
'name': 'Услуги', 'quantity': 1, 'price_unit': 1000.0,
'tax_ids': [(6, 0, self._vat5_tax().ids)]})],
})
tmpl = self._advance_template()
self._matrix(tmpl)
pay = self._post_advance(amount=1050.0, vat=0.0)
invoices = self.env['rsbu.invoice'].search([
('partner_id', '=', self.partner.id),
('invoice_type', '=', 'advance'),
('company_id', '=', self.company.id)])
self.assertTrue(invoices, 'Авансовый СФ должен создаться автоматически')
self.assertEqual(invoices[0].state, 'confirmed')
def _vat5_tax(self):
tax = self.env['account.tax'].search([
('amount', '=', 5.0), ('type_tax_use', '=', 'sale'),
('company_id', '=', self.company.id)], limit=1)
if not tax:
tax = self.env['account.tax'].create({
'name': 'НДС 5%', 'amount': 5.0, 'type_tax_use': 'sale',
'company_id': self.company.id})
return tax
@tagged('post_install', '-at_install')
@freeze_time('2027-01-15')
class TestPeriodClosePl(TransactionCase):
"""Закрытие месяца: 26→90.08, 90→99, 91→99, реформация 99→84 (декабрь).
Дата заморожена на 2027-01-15: все месяцы 2026 завершены, поэтому
проверка «месяц завершён» в _check_can_close не мешает тестам.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
# Изолируем тесты финрезультата от налога УСН (ставка 0 → УСН=0,
# проводок начисления на 99 не будет).
cls.company.rsbu_usn_rate = 0.0
cls.Account = cls.env['rsbu.account']
cls.Move = cls.env['rsbu.move']
# Догрузить типовой план (post_init_hook отрабатывает только при
# установке; в тестовой БД гарантируем наличие 90.08/90.09/91.09/84).
cls.env['rsbu.account.load.wizard'].create({
'company_id': cls.company.id}).action_load()
def _acc(self, code):
return self.Account.search([
('code', '=', code),
('company_id', '=', self.company.id)], limit=1)
def _post(self, date, lines):
"""Создать и провести rsbu.move из списка (code, debit, credit)."""
move = self.Move.create({
'date': date, 'company_id': self.company.id,
'line_ids': [(0, 0, {
'account_id': self._acc(code).id,
'debit': d, 'credit': c,
'name': code}) for code, d, c in lines],
})
move.action_post()
return move
def _close(self, month_date):
pc = self.env['rsbu.period.close'].create({
'period_month': month_date, 'company_id': self.company.id})
pc.action_close()
return pc
def _turnover(self, code, date_from, date_to):
return self._acc(code).get_turnover(
date_from=date_from, date_to=date_to,
company_id=self.company.id, include_children=False)
def test_close_expenses_26_to_9008(self):
"""Директ-костинг: 26 закрывается на 90.08, обнуляется."""
# Расходы месяца: Дт 26 — Кт 51 (60 000). Контрсчёт 51 — без субконто.
self._post('2026-02-15', [('26', 60000.0, 0.0), ('51', 0.0, 60000.0)])
self._close('2026-02-01')
# Оборот 26 за месяц: дебет 60 000 (расход) и кредит 60 000 (закрытие).
t26 = self._turnover('26', '2026-02-01', '2026-02-28')
self.assertEqual(round(t26['debit'] - t26['credit'], 2), 0.0,
'Счёт 26 должен обнулиться закрытием')
# 90.08 получил расходы по дебету.
t9008 = self._turnover('90.08', '2026-02-01', '2026-02-28')
self.assertEqual(t9008['debit'], 60000.0)
def test_close_90_profit_to_99(self):
"""Прибыль от продаж переносится на 99 (Дт 90.09 — Кт 99.01.1)."""
profit_code = '99.01.1' if self._acc('99.01.1') else '99'
# Выручка 100 000 (Кт 90.01), НДС 5000 (Дт 90.03), расходы 40 000 (26).
# Контрсчёт 51 — без обязательных субконто.
self._post('2026-03-31', [
('90.01', 0.0, 100000.0), ('51', 100000.0, 0.0)])
self._post('2026-03-31', [
('90.03', 5000.0, 0.0), ('68.НДС', 0.0, 5000.0)])
self._post('2026-03-15', [('26', 40000.0, 0.0), ('51', 0.0, 40000.0)])
self._close('2026-03-01')
# Финрезультат = 100000 − 5000 − 40000 = 55000 прибыль на 99 (кредит).
bal99 = self._acc(profit_code).get_balance(
date_to='2026-03-31', company_id=self.company.id,
include_children=True)
self.assertEqual(bal99['side'], 'credit')
self.assertEqual(round(bal99['balance'], 2), 55000.0)
# Субсчёт 90.09 обнулён (весь финрезультат ушёл на 99).
b9009 = self._acc('90.09').get_balance(
date_to='2026-03-31', company_id=self.company.id,
include_children=False)
self.assertEqual(round(b9009['balance'], 2), 0.0)
def test_reform_balance_december(self):
"""Реформация декабря: сальдо 99 переносится на 84."""
profit_code = '99.01.1' if self._acc('99.01.1') else '99'
# Прибыль декабря: выручка 80 000, расходов нет.
self._post('2026-12-31', [
('90.01', 0.0, 80000.0), ('51', 80000.0, 0.0)])
self._close('2026-12-01')
# После реформации 99 обнулён, 84 — кредитовое сальдо 80 000 (прибыль).
bal99 = self._acc('99').get_balance(
date_to='2026-12-31', company_id=self.company.id,
include_children=True)
self.assertEqual(round(bal99['balance'], 2), 0.0,
'Счёт 99 должен обнулиться реформацией')
bal84 = self._acc('84').get_balance(
date_to='2026-12-31', company_id=self.company.id)
self.assertEqual(bal84['side'], 'credit')
self.assertEqual(round(bal84['balance'], 2), 80000.0)
def test_reopen_reverts_closing(self):
"""Переоткрытие удаляет все регламентные проводки закрытия."""
self._post('2026-04-15', [('26', 30000.0, 0.0), ('51', 0.0, 30000.0)])
pc = self._close('2026-04-01')
self.assertTrue(pc.move_ids)
self.assertEqual(pc.state, 'done')
pc.action_reopen()
self.assertFalse(pc.move_ids)
self.assertEqual(pc.state, 'draft')
# 26 снова с дебетовым оборотом (закрытие откатилось).
t26 = self._turnover('26', '2026-04-01', '2026-04-30')
self.assertEqual(round(t26['debit'] - t26['credit'], 2), 30000.0)
def test_accounting_start_allows_first_month(self):
"""Месяц начала учёта закрывается свободно (предыдущий не нужен)."""
# Есть проводки и в мае, и в июне, но учёт ведётся с июня.
self.company.rsbu_accounting_start = '2026-06-01'
self._post('2026-05-20', [('26', 10000.0, 0.0), ('51', 0.0, 10000.0)])
self._post('2026-06-20', [('26', 20000.0, 0.0), ('51', 0.0, 20000.0)])
# Июнь (месяц начала учёта) закрывается без требования закрыть май.
pc = self._close('2026-06-01')
self.assertEqual(pc.state, 'done')
def test_accounting_start_blocks_earlier_month(self):
"""Нельзя закрыть месяц раньше даты начала учёта."""
from odoo.exceptions import UserError
self.company.rsbu_accounting_start = '2026-06-01'
self._post('2026-05-20', [('26', 10000.0, 0.0), ('51', 0.0, 10000.0)])
pc = self.env['rsbu.period.close'].create({
'period_month': '2026-05-01', 'company_id': self.company.id})
with self.assertRaises(UserError):
pc.action_close()
def test_cannot_close_future_month(self):
"""Нельзя закрыть незавершённый/будущий месяц (заморожен 2027-01-15)."""
from odoo.exceptions import UserError
pc = self.env['rsbu.period.close'].create({
'period_month': '2027-02-01', 'company_id': self.company.id})
with self.assertRaises(UserError):
pc.action_close()
def test_close_requires_previous_month(self):
"""Нельзя закрыть месяц, если предыдущий (с проводками) не закрыт."""
from odoo.exceptions import UserError
# Проводки в марте и апреле.
self._post('2026-03-15', [('26', 10000.0, 0.0), ('51', 0.0, 10000.0)])
self._post('2026-04-15', [('26', 20000.0, 0.0), ('51', 0.0, 20000.0)])
# Апрель без закрытого марта — блокируется.
pc_apr = self.env['rsbu.period.close'].create({
'period_month': '2026-04-01', 'company_id': self.company.id})
with self.assertRaises(UserError):
pc_apr.action_close()
# Закрываем март, затем апрель проходит.
self._close('2026-03-01')
pc_apr.action_close()
self.assertEqual(pc_apr.state, 'done')

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_rsbu_vat" model="ir.ui.view">
<field name="name">res.config.settings.view.form.rsbu.vat</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="31"/>
<field name="inherit_id" ref="rsbu_kudir.res_config_settings_view_form_rsbu"/>
<field name="arch" type="xml">
<block name="rsbu_usn_setting_container" position="after">
<block title="НДС на УСН" name="rsbu_vat_setting_container">
<setting id="rsbu_vat_mode_setting"
string="Режим НДС"
help="Ставка НДС для УСН (освобождён / 5% / 7%)">
<field name="rsbu_vat_mode"/>
</setting>
</block>
</block>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_invoice_list" model="ir.ui.view">
<field name="name">rsbu.invoice.list</field>
<field name="model">rsbu.invoice</field>
<field name="arch" type="xml">
<list string="Счета-фактуры">
<field name="name"/>
<field name="date"/>
<field name="invoice_type"/>
<field name="partner_id"/>
<field name="base_amount"/>
<field name="vat_amount"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_invoice_form" model="ir.ui.view">
<field name="name">rsbu.invoice.form</field>
<field name="model">rsbu.invoice</field>
<field name="arch" type="xml">
<form string="Счёт-фактура">
<header>
<button name="action_confirm" type="object"
string="Подтвердить" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_cancel" type="object"
string="Отменить"
invisible="state == 'cancelled'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="invoice_type"/>
<field name="date"/>
<field name="partner_id"/>
</group>
<group>
<field name="vat_rate"/>
<field name="base_amount"/>
<field name="vat_amount"/>
<field name="currency_id" invisible="1"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<group string="Источник">
<field name="origin_move_id" readonly="1"
invisible="invoice_type != 'advance'"/>
<field name="source_model" readonly="1"/>
<field name="source_res_id" readonly="1"/>
<field name="rsbu_move_id" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_rsbu_invoice_search" model="ir.ui.view">
<field name="name">rsbu.invoice.search</field>
<field name="model">rsbu.invoice</field>
<field name="arch" type="xml">
<search string="Счета-фактуры">
<field name="name"/>
<field name="partner_id"/>
<filter name="type_advance" string="Авансовые"
domain="[('invoice_type', '=', 'advance')]"/>
<filter name="type_shipment" string="Отгрузочные"
domain="[('invoice_type', '=', 'shipment')]"/>
<separator/>
<filter name="state_confirmed" string="Подтверждённые"
domain="[('state', '=', 'confirmed')]"/>
<filter name="state_draft" string="Черновики"
domain="[('state', '=', 'draft')]"/>
<separator/>
<filter name="group_type" string="Тип СФ"
context="{'group_by': 'invoice_type'}"/>
<filter name="group_partner" string="Контрагент"
context="{'group_by': 'partner_id'}"/>
</search>
</field>
</record>
<record id="action_rsbu_invoice" model="ir.actions.act_window">
<field name="name">Счета-фактуры</field>
<field name="res_model">rsbu.invoice</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_rsbu_invoice_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Журнал счетов-фактур — в разделе «Операции». -->
<menuitem id="menu_rsbu_invoice" name="Журнал счетов-фактур"
parent="rsbu_chart_of_accounts.menu_rsbu_operations"
action="action_rsbu_invoice" sequence="20"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
<!-- Документы реализации — рядом с журналом счетов-фактур. -->
<menuitem id="menu_rsbu_realization" name="Документы реализации"
parent="rsbu_chart_of_accounts.menu_rsbu_operations"
action="action_rsbu_realization" sequence="25"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
<!-- Закрытие периода и пересчёты — в «Регламентные операции». -->
<menuitem id="menu_rsbu_period_close" name="Закрытие периода"
parent="rsbu_chart_of_accounts.menu_rsbu_regular_ops"
action="action_rsbu_period_close" sequence="10"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
<menuitem id="menu_rsbu_vat_recompute" name="Пересчёт книг НДС"
parent="rsbu_chart_of_accounts.menu_rsbu_regular_ops"
action="action_rsbu_vat_recompute_wizard" sequence="30"
groups="rsbu_journal.group_rsbu_journal_user"/>
<!-- Книги и итоги НДС — в «Отчётность». -->
<menuitem id="menu_rsbu_vat_book" name="Книги продаж/покупок"
parent="rsbu_chart_of_accounts.menu_rsbu_reports"
action="action_rsbu_vat_book_line" sequence="60"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
<menuitem id="menu_rsbu_vat_summary" name="Итоги НДС"
parent="rsbu_chart_of_accounts.menu_rsbu_reports"
action="action_rsbu_vat_summary" sequence="70"
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
</odoo>

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_period_close_list" model="ir.ui.view">
<field name="name">rsbu.period.close.list</field>
<field name="model">rsbu.period.close</field>
<field name="arch" type="xml">
<list string="Закрытие месяца">
<field name="name"/>
<field name="period_month"/>
<field name="usn_tax_amount"/>
<field name="move_count" string="Проводок"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_period_close_form" model="ir.ui.view">
<field name="name">rsbu.period.close.form</field>
<field name="model">rsbu.period.close</field>
<field name="arch" type="xml">
<form string="Закрытие месяца">
<header>
<button name="compute_usn_tax" type="object"
string="Рассчитать налог УСН"
invisible="state != 'draft'"/>
<button name="action_close" type="object"
string="Закрыть месяц" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_close_expenses" type="object"
string="Закрыть 26 → 90.08"
invisible="state != 'draft'"/>
<button name="action_close_pl" type="object"
string="Закрыть 90/91 → 99"
invisible="state != 'draft'"/>
<button name="action_vat_deduction" type="object"
string="Вычет ав. НДС"
invisible="state != 'draft'"/>
<button name="action_ens_transfer" type="object"
string="Перенос на ЕНС"
invisible="state != 'draft'"/>
<button name="action_accrue_usn" type="object"
string="Начислить УСН"
invisible="state != 'draft'"/>
<button name="action_reform_balance" type="object"
string="Реформация баланса"
invisible="state != 'draft'"/>
<button name="action_reopen" type="object"
string="Переоткрыть"
invisible="state != 'done'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Период">
<field name="period_month"/>
<field name="date_from" readonly="1"/>
<field name="date_to" readonly="1"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
<group string="Налог УСН (нараст. с начала года)">
<field name="usn_tax_base" readonly="1"/>
<field name="usn_reduction"/>
<field name="usn_advance_paid" readonly="1"/>
<field name="usn_tax_amount"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<notebook>
<page string="Регламентные проводки"
name="moves">
<field name="move_ids" readonly="1">
<list>
<field name="date"/>
<field name="ref"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_rsbu_period_close" model="ir.actions.act_window">
<field name="name">Закрытие периода</field>
<field name="res_model">rsbu.period.close</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_realization_list" model="ir.ui.view">
<field name="name">rsbu.realization.list</field>
<field name="model">rsbu.realization</field>
<field name="arch" type="xml">
<list string="Документы реализации">
<field name="name"/>
<field name="date"/>
<field name="partner_id"/>
<field name="amount_untaxed"/>
<field name="amount_tax"/>
<field name="upd_function"/>
<field name="invoice_id"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_realization_form" model="ir.ui.view">
<field name="name">rsbu.realization.form</field>
<field name="model">rsbu.realization</field>
<field name="arch" type="xml">
<form string="Документ реализации">
<header>
<button name="action_cancel" type="object"
string="Отменить"
invisible="state == 'cancelled'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="date"/>
<field name="partner_id"/>
<field name="upd_function"/>
</group>
<group>
<field name="amount_untaxed"/>
<field name="amount_tax"/>
<field name="invoice_id" readonly="1"/>
<field name="currency_id" invisible="1"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<group string="Реквизиты УПД">
<group>
<field name="upd_shipper_id"/>
<field name="upd_consignee_id"/>
<field name="upd_transferred_by_id"/>
<field name="upd_received_by_id"/>
</group>
<group>
<field name="upd_transport"/>
<field name="upd_basis"/>
<field name="upd_operation_content"/>
</group>
</group>
<group string="Источник">
<field name="source_model" readonly="1"/>
<field name="source_res_id" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_rsbu_realization_search" model="ir.ui.view">
<field name="name">rsbu.realization.search</field>
<field name="model">rsbu.realization</field>
<field name="arch" type="xml">
<search string="Документы реализации">
<field name="name"/>
<field name="partner_id"/>
<filter name="with_sf" string="С счётом-фактурой"
domain="[('invoice_id', '!=', False)]"/>
<filter name="without_sf" string="Без счёта-фактуры"
domain="[('invoice_id', '=', False)]"/>
<separator/>
<filter name="state_confirmed" string="Подтверждённые"
domain="[('state', '=', 'confirmed')]"/>
<filter name="state_draft" string="Черновики"
domain="[('state', '=', 'draft')]"/>
<separator/>
<filter name="group_partner" string="Контрагент"
context="{'group_by': 'partner_id'}"/>
</search>
</field>
</record>
<record id="action_rsbu_realization" model="ir.actions.act_window">
<field name="name">Документы реализации</field>
<field name="res_model">rsbu.realization</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_rsbu_realization_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_vat_book_line_list" model="ir.ui.view">
<field name="name">rsbu.vat.book.line.list</field>
<field name="model">rsbu.vat.book.line</field>
<field name="arch" type="xml">
<list string="Книги НДС">
<field name="date"/>
<field name="book"/>
<field name="partner_id"/>
<field name="base_amount"/>
<field name="vat_amount" sum="Итого НДС"/>
<field name="period_key"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_vat_book_line_search" model="ir.ui.view">
<field name="name">rsbu.vat.book.line.search</field>
<field name="model">rsbu.vat.book.line</field>
<field name="arch" type="xml">
<search string="Поиск по книгам НДС">
<field name="partner_id"/>
<filter name="sales" string="Книга продаж"
domain="[('book', '=', 'sales')]"/>
<filter name="purchases" string="Книга покупок"
domain="[('book', '=', 'purchases')]"/>
<separator/>
<filter name="group_book" string="Книга"
context="{'group_by': 'book'}"/>
<filter name="group_period" string="Квартал"
context="{'group_by': 'period_key'}"/>
</search>
</field>
</record>
<record id="action_rsbu_vat_book_line" model="ir.actions.act_window">
<field name="name">Книги НДС</field>
<field name="res_model">rsbu.vat.book.line</field>
<field name="view_mode">list</field>
<field name="search_view_id" ref="view_rsbu_vat_book_line_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_vat_summary_list" model="ir.ui.view">
<field name="name">rsbu.vat.summary.list</field>
<field name="model">rsbu.vat.summary</field>
<field name="arch" type="xml">
<list string="Итоги НДС">
<field name="period_key"/>
<field name="vat_charged"/>
<field name="vat_deducted"/>
<field name="vat_payable"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="action_rsbu_vat_summary" model="ir.actions.act_window">
<field name="name">Итоги НДС</field>
<field name="res_model">rsbu.vat.summary</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import rsbu_vat_recompute_wizard
from . import rsbu_vat_declaration_wizard

View File

@@ -0,0 +1,61 @@
from odoo import fields, models
class RsbuVatDeclarationWizard(models.TransientModel):
"""Визард быстрого просмотра итогов НДС на экране.
Полноценная декларация НДС с выгрузкой XML (комплект из 3 файлов по
формату ФНС 5.12) — постоянная модель rsbu.nds.declaration в модуле
rsbu_fns_reporting (меню «Отчётность ФНС → Декларация НДС»). Этот
визард оставлен для быстрой прикидки сумм за произвольный период.
"""
_name = 'rsbu.vat.declaration.wizard'
_description = 'РСБУ — Расчёт декларации НДС'
date_from = fields.Date(string='Дата с', required=True)
date_to = fields.Date(string='Дата по', required=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True,
default=lambda self: self.env.company)
currency_id = fields.Many2one(
related='company_id.currency_id')
tax_base = fields.Monetary(string='Налоговая база', readonly=True,
currency_field='currency_id')
vat_charged = fields.Monetary(string='НДС начислен', readonly=True,
currency_field='currency_id')
vat_deducted = fields.Monetary(string='Вычеты', readonly=True,
currency_field='currency_id')
vat_payable = fields.Monetary(string='НДС к уплате', readonly=True,
currency_field='currency_id')
computed = fields.Boolean(default=False)
def action_compute(self):
self.ensure_one()
# Гарантируем актуальность книг/итогов.
self.env['rsbu.vat.book.line'].recompute_period(
self.date_from, self.date_to, self.company_id)
self.env['rsbu.vat.summary'].recompute_period(
self.date_from, self.date_to, self.company_id)
books = self.env['rsbu.vat.book.line'].search([
('company_id', '=', self.company_id.id),
('date', '>=', self.date_from),
('date', '<=', self.date_to),
])
charged = sum(books.filtered(lambda b: b.book == 'sales')
.mapped('vat_amount'))
deducted = sum(books.filtered(lambda b: b.book == 'purchases')
.mapped('vat_amount'))
self.write({
'vat_charged': charged,
'vat_deducted': deducted,
'vat_payable': charged - deducted,
'computed': True,
})
return {
'type': 'ir.actions.act_window',
'res_model': 'rsbu.vat.declaration.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_vat_declaration_wizard_form" model="ir.ui.view">
<field name="name">rsbu.vat.declaration.wizard.form</field>
<field name="model">rsbu.vat.declaration.wizard</field>
<field name="arch" type="xml">
<form string="Расчёт декларации НДС">
<group>
<field name="date_from"/>
<field name="date_to"/>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="currency_id" invisible="1"/>
<field name="computed" invisible="1"/>
</group>
<group string="Итоги за период" invisible="not computed">
<field name="vat_charged"/>
<field name="vat_deducted"/>
<field name="vat_payable"/>
</group>
<footer>
<button name="action_compute" type="object"
string="Рассчитать" class="btn-primary"/>
<button string="Закрыть" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_rsbu_vat_declaration_wizard" model="ir.actions.act_window">
<field name="name">Расчёт декларации НДС</field>
<field name="res_model">rsbu.vat.declaration.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
from odoo import fields, models, _
class RsbuVatRecomputeWizard(models.TransientModel):
"""Визард пересчёта книг НДС и итогов за период."""
_name = 'rsbu.vat.recompute.wizard'
_description = 'РСБУ — Пересчёт книг НДС'
date_from = fields.Date(string='Дата с', required=True)
date_to = fields.Date(string='Дата по', required=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True,
default=lambda self: self.env.company)
def action_recompute(self):
self.ensure_one()
self.env['rsbu.vat.book.line'].recompute_period(
self.date_from, self.date_to, self.company_id)
self.env['rsbu.vat.summary'].recompute_period(
self.date_from, self.date_to, self.company_id)
return {
'type': 'ir.actions.act_window',
'name': _('Книги НДС'),
'res_model': 'rsbu.vat.book.line',
'view_mode': 'list',
'domain': [
('company_id', '=', self.company_id.id),
('date', '>=', self.date_from),
('date', '<=', self.date_to),
],
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_vat_recompute_wizard_form" model="ir.ui.view">
<field name="name">rsbu.vat.recompute.wizard.form</field>
<field name="model">rsbu.vat.recompute.wizard</field>
<field name="arch" type="xml">
<form string="Пересчёт книг НДС">
<group>
<field name="date_from"/>
<field name="date_to"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
<footer>
<button name="action_recompute" type="object"
string="Пересчитать" class="btn-primary"/>
<button string="Отмена" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_rsbu_vat_recompute_wizard" model="ir.actions.act_window">
<field name="name">Пересчёт книг НДС</field>
<field name="res_model">rsbu.vat.recompute.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>