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_journal/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|
Журнал бухгалтерских проводок по российским стандартам (РСБУ) — ядро
РСБУ-слоя. Ведёт учёт методом двойной записи по плану счетов 94н и
обеспечивает расчёт оборотов и сальдо. Нативная бухгалтерия Odoo не
затрагивается.
**Содержание**
.. contents::
:local:
Возможности
===========
* Проводки по методу двойной записи (дебет / кредит)
* Аналитика (субконто): контрагент, товар, пользователь, сотрудник
* Контроль баланса проводки при проведении
* Контроль обязательности заполнения субконто
* Обороты и сальдо по счетам рассчитываются из проведённых проводок
* Учёт в рублях, поддержка нескольких компаний в одной базе
Использование
=============
#. Перейдите в *РСБУ > Журнал операций*.
#. Создайте проводку: укажите дату, счета по дебету и кредиту и суммы.
#. Заполните субконто, если они обязательны для выбранных счетов.
#. Проведите проводку — она попадёт в обороты и сальдо плана счетов.
Как правило, проводки формируются автоматически при проведении
документов (см. модуль коннектора). Ручной ввод используется для
корректировок и операций без документа-источника.
Авторы
======
* MK.Lab, RuOdoo
Модуль сопровождается MK.Lab, RuOdoo.
.. image:: https://ruodoo.ru/logo.png
:alt: MK.Lab, RuOdoo
:target: https://ruodoo.ru
Модуль является частью РСБУ-слоя проекта РуОду.

3
rsbu_journal/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import models
from . import wizards
from . import report

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
{
'name': 'РСБУ — Журнал хозяйственных операций',
'version': '19.0.2026.09.17',
'summary': 'Журнал операций РСБУ (двойная запись, субконто)',
'description': """
РСБУ — Журнал хозяйственных операций
====================================
Журнал бухгалтерских проводок по российским стандартам (РСБУ) —
ядро РСБУ-слоя. Ведёт двойную запись по плану счетов 94н.
Возможности:
------------
* Проводки по методу двойной записи (дебет / кредит).
* Аналитика (субконто) в разрезе контрагентов, товаров, пользователей
и сотрудников.
* Контроль баланса проводки и обязательности заполнения субконто
при проведении.
* Обороты и сальдо по счетам плана счетов рассчитываются из проведённых
проводок журнала.
Учёт ведётся в рублях, поддерживается работа нескольких компаний в одной
базе. Нативная бухгалтерия Odoo не затрагивается.
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Accounting/Localizations',
'depends': ['rsbu_chart_of_accounts', 'hr', 'product', 'l10n_ru_contract'],
'data': [
'security/rsbu_journal_security.xml',
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'views/rsbu_move_views.xml',
'wizards/rsbu_trial_balance_wizard_views.xml',
'report/rsbu_trial_balance_templates.xml',
'views/rsbu_menus.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_rsbu_move" model="ir.sequence">
<field name="name">РСБУ Операция</field>
<field name="code">rsbu.move</field>
<field name="prefix">RSBU/%(range_year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,4 @@
from . import rsbu_move
from . import rsbu_move_line
from . import rsbu_account
from . import rsbu_trial_balance

View File

@@ -0,0 +1,23 @@
from odoo import models
class RsbuAccount(models.Model):
"""Расширение плана счетов: наполнение хука агрегации данными журнала."""
_inherit = 'rsbu.account'
def _read_postings(self, domain=None):
"""Прочитать проведённые строки журнала для агрегации.
Переопределяет заглушку Этапа 1. Возвращает строки rsbu.move.line
по поддереву счёта, только проведённые (state='posted'), с учётом
дополнительного domain (период/компания) из вызывающего метода.
"""
self.ensure_one()
base_domain = [
('account_id', 'in', self._get_subtree_account_ids()),
('state', '=', 'posted'),
]
if domain:
# Отфильтровать по period/company — поля есть на строке (related).
base_domain += domain
return self.env['rsbu.move.line'].search(base_domain)

View File

@@ -0,0 +1,148 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
class RsbuMove(models.Model):
"""Хозяйственная операция РСБУ (аналог account.move)."""
_name = 'rsbu.move'
_description = 'РСБУ — Хозяйственная операция'
_order = 'date desc, name desc, id desc'
_check_company_auto = True
_inherit = ['mail.thread']
name = fields.Char(
string='Номер', default='/', copy=False, readonly=True, index=True)
date = fields.Date(
string='Дата операции', required=True, index=True,
default=fields.Date.context_today, tracking=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)
state = fields.Selection(
selection=[('draft', 'Черновик'), ('posted', 'Проведён')],
string='Состояние', default='draft', required=True,
copy=False, tracking=True, index=True)
line_ids = fields.One2many(
'rsbu.move.line', 'move_id', string='Строки проводки',
copy=True)
ref = fields.Char(string='Ссылка / описание')
note = fields.Text(string='Примечание')
# Ссылка на документ-источник (используется Этапом 3).
source_model = fields.Char(string='Модель источника', copy=False)
source_res_id = fields.Integer(string='ID источника', copy=False)
# Контрольные обороты по проводке (для проверки баланса, не «сумма
# операции»): при наличии внутренних проводок (напр. НДС на 90.03)
# оборот по дебету не равен сумме документа, поэтому «сумму операции»
# намеренно не храним и не показываем.
total_debit = fields.Monetary(
string='Оборот Дт', compute='_compute_totals', store=True,
currency_field='currency_id')
total_credit = fields.Monetary(
string='Оборот Кт', compute='_compute_totals', store=True,
currency_field='currency_id')
line_count = fields.Integer(
string='Кол-во строк', compute='_compute_line_count')
# ------------------------------------------------------------------
# Вычисляемые поля
# ------------------------------------------------------------------
@api.depends('company_id')
def _compute_currency_id(self):
for move in self:
move.currency_id = move.company_id.currency_id
@api.depends('line_ids.debit', 'line_ids.credit')
def _compute_totals(self):
for move in self:
move.total_debit = sum(move.line_ids.mapped('debit'))
move.total_credit = sum(move.line_ids.mapped('credit'))
@api.depends('line_ids')
def _compute_line_count(self):
for move in self:
move.line_count = len(move.line_ids)
# ------------------------------------------------------------------
# Проверки при проведении
# ------------------------------------------------------------------
def _check_has_lines(self):
for move in self:
if not move.line_ids:
raise UserError(_(
'Нельзя провести операцию «%s» без строк.')
% move.display_name)
def _check_balanced(self):
for move in self:
rounding = move.currency_id.rounding or 0.01
total_debit = sum(move.line_ids.mapped('debit'))
total_credit = sum(move.line_ids.mapped('credit'))
if not float_is_zero(total_debit - total_credit,
precision_rounding=rounding):
raise UserError(_(
'Операция «%(name)s» не сбалансирована: '
'дебет %(debit)s ≠ кредит %(credit)s.',
name=move.display_name,
debit=total_debit, credit=total_credit))
def _check_required_subconto(self):
for move in self:
for line in move.line_ids:
required = line.account_id.get_required_subconto()
missing = [dim for dim in required
if not line._has_subconto(dim)]
if missing:
labels = {'partner': 'Контрагент', 'product': 'Номенклатура',
'user': 'Пользователь', 'employee': 'Сотрудник'}
names = ', '.join(labels.get(m, m) for m in missing)
raise UserError(_(
'Строка со счётом «%(acc)s» требует обязательные '
'субконто: %(subs)s.',
acc=line.account_id.display_name, subs=names))
# ------------------------------------------------------------------
# Жизненный цикл
# ------------------------------------------------------------------
def action_post(self):
for move in self:
if move.state == 'posted':
continue
move._check_has_lines()
move._check_balanced()
move._check_required_subconto()
if move.name == '/' or not move.name:
move.name = self.env['ir.sequence'].with_company(
move.company_id).next_by_code('rsbu.move') or '/'
move.state = 'posted'
return True
def action_draft(self):
for move in self:
move.state = 'draft'
return True
def write(self, vals):
# Ограничение редактирования ключевых полей у проведённой операции.
protected = {'line_ids', 'date', 'company_id'}
if any(f in vals for f in protected):
for move in self:
if move.state == 'posted':
raise UserError(_(
'Операция «%s» проведена. Верните её в черновик '
'перед изменением строк или даты.')
% move.display_name)
return super().write(vals)
@api.ondelete(at_uninstall=False)
def _unlink_only_draft(self):
for move in self:
if move.state == 'posted':
raise UserError(_(
'Нельзя удалить проведённую операцию «%s». '
'Сначала верните её в черновик.') % move.display_name)

View File

@@ -0,0 +1,92 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import float_is_zero
# Соответствие измерений субконто полям строки.
SUBCONTO_FIELD = {
'partner': 'partner_id',
'product': 'product_id',
'user': 'user_id',
'employee': 'employee_id',
'origin': 'origin_move_id',
'contract': 'contract_id',
}
class RsbuMoveLine(models.Model):
"""Строка проводки РСБУ (двойная запись, субконто)."""
_name = 'rsbu.move.line'
_description = 'РСБУ — Строка проводки'
_check_company_auto = True
move_id = fields.Many2one(
'rsbu.move', string='Операция', required=True,
ondelete='cascade', index=True)
account_id = fields.Many2one(
'rsbu.account', string='Счёт', required=True,
check_company=True, index=True)
name = fields.Char(string='Описание')
debit = fields.Monetary(string='Дебет', default=0.0,
currency_field='currency_id')
credit = fields.Monetary(string='Кредит', default=0.0,
currency_field='currency_id')
# Субконто.
partner_id = fields.Many2one('res.partner', string='Контрагент',
check_company=True)
product_id = fields.Many2one('product.product', string='Номенклатура')
user_id = fields.Many2one('res.users', string='Пользователь')
employee_id = fields.Many2one('hr.employee', string='Сотрудник',
check_company=True)
# Субконто «Документ-основание»: проводка-основание расчёта (аванс),
# аналог 3-го субконто 1С на 62.02/60.02/76.АВ («Документы расчётов»).
# Позволяет вести остаток и зачёт по каждому авансу отдельно.
origin_move_id = fields.Many2one(
'rsbu.move', string='Документ-основание', index=True,
ondelete='restrict')
# Субконто «Договор» — аналог 2-го субконто 1С «Договоры» на 60/62/76.
# Расчёты и зачёт авансов ведутся в разрезе (контрагент, договор).
contract_id = fields.Many2one(
'partner.contract.customer', string='Договор', index=True,
ondelete='restrict')
company_id = fields.Many2one(
related='move_id.company_id', string='Компания',
store=True, index=True)
currency_id = fields.Many2one(
related='move_id.currency_id', string='Валюта', store=True)
date = fields.Date(related='move_id.date', store=True, index=True)
state = fields.Selection(
related='move_id.state', store=True, index=True)
# ------------------------------------------------------------------
# Ограничения
# ------------------------------------------------------------------
@api.constrains('debit', 'credit')
def _check_debit_credit(self):
for line in self:
rounding = line.currency_id.rounding or 0.01
debit_zero = float_is_zero(line.debit, precision_rounding=rounding)
credit_zero = float_is_zero(line.credit, precision_rounding=rounding)
if debit_zero and credit_zero:
raise ValidationError(_(
'Строка проводки должна иметь ненулевую сумму '
'по дебету или кредиту.'))
if not debit_zero and not credit_zero:
raise ValidationError(_(
'Строка проводки не может иметь одновременно дебет '
'и кредит. Заполните только одну сторону.'))
@api.constrains('account_id')
def _check_account_not_group(self):
for line in self:
if line.account_id.is_group:
raise ValidationError(_(
'Нельзя относить проводку на счёт-группу «%s».')
% line.account_id.display_name)
def _has_subconto(self, dimension):
"""Заполнено ли измерение субконто в строке."""
self.ensure_one()
field_name = SUBCONTO_FIELD.get(dimension)
return bool(field_name and self[field_name])

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""Оборотно-сальдовая ведомость (ОСВ) по РСБУ-проводкам.
Строится за период по проведённым строкам rsbu.move.line, сгруппированным
по счёту плана счетов. Для каждого счёта показываются шесть показателей:
сальдо на начало (Дт/Кт), обороты за период (Дт/Кт), сальдо на конец
(Дт/Кт) — как в 1С.
Особенности:
* сальдо активно-пассивных счетов (60/62/76) — РАЗВЁРНУТОЕ (дебиторка и
кредиторка одновременно), считается в разрезе субконто через
rsbu.account.get_expanded_balance;
* иерархия счетов сворачивается снизу вверх: показатели счёта-группы =
сумма показателей его потомков-листьев;
* начало года для сальдо — календарное (1 января), результатные счета
90/91/99 к концу года закрываются регламентом закрытия месяца.
Строки живут в рамках пользовательской сессии (TransientModel); отчёт на
экране (QWeb-HTML) и переход к проводкам (drill-down) строятся поверх них.
"""
from odoo import api, fields, models
class RsbuTrialBalanceLine(models.TransientModel):
_name = 'rsbu.trial.balance.line'
_description = 'РСБУ — Строка оборотно-сальдовой ведомости'
_order = 'code'
wizard_id = fields.Many2one(
'rsbu.trial.balance.wizard', string='Отчёт', ondelete='cascade',
index=True)
account_id = fields.Many2one('rsbu.account', string='Счёт', index=True)
code = fields.Char(string='Код счёта', index=True)
name = fields.Char(string='Наименование счёта')
level = fields.Integer(string='Уровень')
is_group = fields.Boolean(string='Счёт-группа')
parent_code = fields.Char(string='Код родителя')
company_id = fields.Many2one('res.company', string='Компания')
currency_id = fields.Many2one('res.currency', string='Валюта')
opening_debit = fields.Monetary(string='Сальдо нач. Дт',
currency_field='currency_id')
opening_credit = fields.Monetary(string='Сальдо нач. Кт',
currency_field='currency_id')
turnover_debit = fields.Monetary(string='Оборот Дт',
currency_field='currency_id')
turnover_credit = fields.Monetary(string='Оборот Кт',
currency_field='currency_id')
closing_debit = fields.Monetary(string='Сальдо кон. Дт',
currency_field='currency_id')
closing_credit = fields.Monetary(string='Сальдо кон. Кт',
currency_field='currency_id')
def action_open_entries(self):
"""Drill-down: открыть проводки счёта за период отчёта."""
self.ensure_one()
wiz = self.wizard_id
domain = [
('account_id', 'child_of', self.account_id.id),
('state', '=', 'posted'),
('company_id', '=', self.company_id.id),
('date', '>=', wiz.date_from),
('date', '<=', wiz.date_to),
]
return {
'type': 'ir.actions.act_window',
'name': '%s %s' % (self.code, self.name or ''),
'res_model': 'rsbu.move.line',
'view_mode': 'list,form',
'domain': domain,
'context': {'search_default_group_account': 0},
}

View File

@@ -0,0 +1 @@
from . import rsbu_trial_balance_report

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Отчётная модель ОСВ (QWeb-HTML на экран)."""
from odoo import api, models
class RsbuTrialBalanceReport(models.AbstractModel):
_name = 'report.rsbu_journal.report_rsbu_trial_balance'
_description = 'РСБУ — Отчёт ОСВ'
@api.model
def _get_report_values(self, docids, data=None):
wizards = self.env['rsbu.trial.balance.wizard'].browse(docids)
return {
'doc_ids': docids,
'doc_model': 'rsbu.trial.balance.wizard',
'docs': wizards,
'totals_by_wizard': {w.id: w._report_totals() for w in wizards},
}

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_rsbu_trial_balance">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.internal_layout">
<div class="page osv-report">
<style>
.osv-report { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; }
.osv-table { width: 100%; border-collapse: collapse; font-size: 12px; font-family: inherit; }
.osv-table th, .osv-table td { border: 1px solid #b7c3b0; padding: 3px 6px; }
.osv-table thead th { background: #d7e4c9; text-align: center; font-weight: bold; }
.osv-table td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.osv-table tr.grp td { background: #eaf2e1; font-weight: bold; }
.osv-table tr.total td { background: #cddcc0; font-weight: bold; }
.osv-acc { white-space: nowrap; }
</style>
<h3>
Оборотно-сальдовая ведомость
</h3>
<p>
за период
<strong><span t-field="doc.date_from"/></strong>
—
<strong><span t-field="doc.date_to"/></strong>,
<span t-field="doc.company_id.name"/>
</p>
<table class="osv-table">
<thead>
<tr>
<th rowspan="2">Счёт</th>
<th rowspan="2">Наименование</th>
<th colspan="2">Сальдо на начало периода</th>
<th colspan="2">Обороты за период</th>
<th colspan="2">Сальдо на конец периода</th>
</tr>
<tr>
<th>Дебет</th>
<th>Кредит</th>
<th>Дебет</th>
<th>Кредит</th>
<th>Дебет</th>
<th>Кредит</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.line_ids" t-as="line">
<tr t-att-class="'grp' if line.is_group else ''">
<td class="osv-acc"
t-att-style="'padding-left: %dpx' % (6 + 14 * (line.level or 0))">
<span t-esc="line.code"/>
</td>
<td><span t-esc="line.name"/></td>
<td class="num">
<span t-esc="line.opening_debit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.opening_debit"/>
</td>
<td class="num">
<span t-esc="line.opening_credit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.opening_credit"/>
</td>
<td class="num">
<span t-esc="line.turnover_debit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.turnover_debit"/>
</td>
<td class="num">
<span t-esc="line.turnover_credit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.turnover_credit"/>
</td>
<td class="num">
<span t-esc="line.closing_debit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.closing_debit"/>
</td>
<td class="num">
<span t-esc="line.closing_credit"
t-options='{"widget": "monetary", "display_currency": line.currency_id}'
t-if="line.closing_credit"/>
</td>
</tr>
</t>
<t t-set="tot" t-value="totals_by_wizard[doc.id]"/>
<tr class="total">
<td colspan="2">Итого</td>
<td class="num">
<span t-esc="tot['opening_debit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
<td class="num">
<span t-esc="tot['opening_credit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
<td class="num">
<span t-esc="tot['turnover_debit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
<td class="num">
<span t-esc="tot['turnover_credit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
<td class="num">
<span t-esc="tot['closing_debit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
<td class="num">
<span t-esc="tot['closing_credit']"
t-options='{"widget": "monetary", "display_currency": doc.company_id.currency_id}'/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
<record id="action_report_rsbu_trial_balance" model="ir.actions.report">
<field name="name">Оборотно-сальдовая ведомость</field>
<field name="model">rsbu.trial.balance.wizard</field>
<field name="report_type">qweb-html</field>
<field name="report_name">rsbu_journal.report_rsbu_trial_balance</field>
<field name="report_file">rsbu_journal.report_rsbu_trial_balance</field>
<field name="print_report_name">'ОСВ %s' % (object.date_to or '')</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_rsbu_move_user,rsbu.move user,model_rsbu_move,group_rsbu_journal_user,1,1,1,0
access_rsbu_move_manager,rsbu.move manager,model_rsbu_move,group_rsbu_journal_manager,1,1,1,1
access_rsbu_move_line_user,rsbu.move.line user,model_rsbu_move_line,group_rsbu_journal_user,1,1,1,1
access_rsbu_move_line_manager,rsbu.move.line manager,model_rsbu_move_line,group_rsbu_journal_manager,1,1,1,1
access_rsbu_tb_wizard_user,rsbu.trial.balance.wizard user,model_rsbu_trial_balance_wizard,group_rsbu_journal_user,1,1,1,1
access_rsbu_tb_line_user,rsbu.trial.balance.line user,model_rsbu_trial_balance_line,group_rsbu_journal_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_rsbu_move_user rsbu.move user model_rsbu_move group_rsbu_journal_user 1 1 1 0
3 access_rsbu_move_manager rsbu.move manager model_rsbu_move group_rsbu_journal_manager 1 1 1 1
4 access_rsbu_move_line_user rsbu.move.line user model_rsbu_move_line group_rsbu_journal_user 1 1 1 1
5 access_rsbu_move_line_manager rsbu.move.line manager model_rsbu_move_line group_rsbu_journal_manager 1 1 1 1
6 access_rsbu_tb_wizard_user rsbu.trial.balance.wizard user model_rsbu_trial_balance_wizard group_rsbu_journal_user 1 1 1 1
7 access_rsbu_tb_line_user rsbu.trial.balance.line user model_rsbu_trial_balance_line group_rsbu_journal_user 1 1 1 1

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="group_rsbu_journal_user" model="res.groups">
<field name="name">РСБУ / Журнал: пользователь</field>
<field name="implied_ids" eval="[(4, ref('rsbu_chart_of_accounts.group_rsbu_account_user'))]"/>
</record>
<record id="group_rsbu_journal_manager" model="res.groups">
<field name="name">РСБУ / Журнал: менеджер</field>
<field name="implied_ids" eval="[(4, ref('group_rsbu_journal_user'))]"/>
</record>
<record id="rsbu_move_company_rule" model="ir.rule">
<field name="name">РСБУ Операция: мультикомпания</field>
<field name="model_id" ref="model_rsbu_move"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<record id="rsbu_move_line_company_rule" model="ir.rule">
<field name="name">РСБУ Строка: мультикомпания</field>
<field name="model_id" ref="model_rsbu_move_line"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import test_rsbu_move
from . import test_trial_balance

View File

@@ -0,0 +1,120 @@
from odoo.tests import TransactionCase, tagged
from odoo.exceptions import UserError, ValidationError
@tagged('post_install', '-at_install')
class TestRsbuMove(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Account = cls.env['rsbu.account']
cls.Move = cls.env['rsbu.move']
cls.company = cls.env.company
# Счета из типового плана (загружен post_init_hook Этапа 1).
cls.acc_51 = cls._acc(cls, '51')
cls.acc_6201 = cls._acc(cls, '62.01')
cls.acc_62av = cls._acc(cls, '62.АВ')
cls.acc_90 = cls._acc(cls, '90') # это группа? нет, синтетический
cls.acc_9001 = cls._acc(cls, '90.01')
cls.partner = cls.env['res.partner'].create({'name': 'Тест Клиент'})
def _acc(self, code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.env.company.id)], limit=1)
def _move(self, lines, date='2026-01-15'):
return self.Move.create({
'date': date,
'line_ids': [(0, 0, l) for l in lines],
})
def test_post_balanced(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 1000.0},
{'account_id': self.acc_62av.id, 'credit': 1000.0,
'partner_id': self.partner.id},
])
move.action_post()
self.assertEqual(move.state, 'posted')
self.assertNotEqual(move.name, '/')
def test_post_unbalanced_fails(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 1000.0},
{'account_id': self.acc_62av.id, 'credit': 900.0,
'partner_id': self.partner.id},
])
with self.assertRaises(UserError):
move.action_post()
def test_post_no_lines_fails(self):
move = self.Move.create({'date': '2026-01-15'})
with self.assertRaises(UserError):
move.action_post()
def test_line_one_side_only(self):
with self.assertRaises(ValidationError):
self._move([
{'account_id': self.acc_51.id, 'debit': 100.0, 'credit': 100.0},
{'account_id': self.acc_62av.id, 'credit': 100.0,
'partner_id': self.partner.id},
])
def test_required_subconto_blocks_post(self):
# 62.АВ требует партнёра — не заполняем.
move = self._move([
{'account_id': self.acc_51.id, 'debit': 500.0},
{'account_id': self.acc_62av.id, 'credit': 500.0},
])
with self.assertRaises(UserError):
move.action_post()
def test_draft_and_delete(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 100.0},
{'account_id': self.acc_62av.id, 'credit': 100.0,
'partner_id': self.partner.id},
])
move.action_post()
move.action_draft()
self.assertEqual(move.state, 'draft')
move.unlink() # из черновика — можно
def test_delete_posted_fails(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 100.0},
{'account_id': self.acc_62av.id, 'credit': 100.0,
'partner_id': self.partner.id},
])
move.action_post()
with self.assertRaises(UserError):
move.unlink()
def test_aggregation_after_post(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 1500.0},
{'account_id': self.acc_62av.id, 'credit': 1500.0,
'partner_id': self.partner.id},
])
move.action_post()
bal_51 = self.acc_51.get_balance(company_id=self.company.id)
self.assertEqual(bal_51['debit'], 1500.0)
self.assertEqual(bal_51['balance'], 1500.0)
self.assertEqual(bal_51['side'], 'debit')
# 62.АВ пассивный — кредитовое сальдо.
bal_av = self.acc_62av.get_balance(company_id=self.company.id)
self.assertEqual(bal_av['credit'], 1500.0)
self.assertEqual(bal_av['balance'], 1500.0)
self.assertEqual(bal_av['side'], 'credit')
def test_draft_moves_excluded_from_aggregation(self):
move = self._move([
{'account_id': self.acc_51.id, 'debit': 700.0},
{'account_id': self.acc_62av.id, 'credit': 700.0,
'partner_id': self.partner.id},
])
# не проводим
bal = self.acc_51.get_balance(company_id=self.company.id)
self.assertEqual(bal['debit'], 0.0)

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestTrialBalance(TransactionCase):
"""Оборотно-сальдовая ведомость: развёрнутое АП-сальдо, баланс, свёртка."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.Account = cls.env['rsbu.account']
cls.Move = cls.env['rsbu.move']
cls.Wizard = cls.env['rsbu.trial.balance.wizard']
cls.partner_a = cls.env['res.partner'].create({'name': 'Дебитор А'})
cls.partner_b = cls.env['res.partner'].create({'name': 'Кредитор Б'})
def _acc(self, code):
return self.Account.search([
('code', '=', code),
('company_id', '=', self.company.id)], limit=1)
def _post(self, lines, date='2026-02-10'):
move = self.Move.create({
'date': date,
'line_ids': [(0, 0, l) for l in lines]})
move.action_post()
return move
def test_expanded_balance_active_passive(self):
"""Счёт 62 (АП): дебиторка и кредиторка показаны РАЗВЁРНУТО.
Партнёр А — долг (Дт 62.01 1000), партнёр Б — аванс (Кт 62.АВ 300).
На синтетическом счёте 62 ОСВ должна показать Дт 1000 И Кт 300
одновременно, а не нетто 700.
"""
acc51 = self._acc('51')
acc6201 = self._acc('62.01')
acc62av = self._acc('62.АВ')
# Дебиторка партнёра А: Дт 62.01 — Кт 51 (1000).
self._post([
{'account_id': acc6201.id, 'debit': 1000.0,
'partner_id': self.partner_a.id},
{'account_id': acc51.id, 'credit': 1000.0},
])
# Аванс партнёра Б: Дт 51 — Кт 62.АВ (300).
self._post([
{'account_id': acc51.id, 'debit': 300.0},
{'account_id': acc62av.id, 'credit': 300.0,
'partner_id': self.partner_b.id},
])
acc62 = self._acc('62')
exp = acc62.get_expanded_balance(
date_to='2026-02-28', company_id=self.company.id)
self.assertEqual(exp['debit'], 1000.0,
'Дебиторка должна быть развёрнута в Дт')
self.assertEqual(exp['credit'], 300.0,
'Аванс должен быть развёрнут в Кт')
def test_wizard_generates_and_balances(self):
"""ОСВ: кон.сальдо = нач.сальдо + обороты (для активного счёта)."""
acc51 = self._acc('51')
acc62av = self._acc('62.АВ')
# Поступление аванса: Дт 51 — Кт 62.АВ (2000) в феврале.
self._post([
{'account_id': acc51.id, 'debit': 2000.0},
{'account_id': acc62av.id, 'credit': 2000.0,
'partner_id': self.partner_a.id},
], date='2026-02-10')
wiz = self.Wizard.create({
'date_from': '2026-02-01', 'date_to': '2026-02-28',
'company_id': self.company.id, 'hide_zero': True})
wiz.action_generate()
line51 = wiz.line_ids.filtered(lambda l: l.code == '51')
self.assertTrue(line51, 'Счёт 51 должен присутствовать в ОСВ')
# 51 активный: нач.сальдо 0, оборот Дт 2000, кон.сальдо Дт 2000.
self.assertEqual(line51.opening_debit, 0.0)
self.assertEqual(line51.turnover_debit, 2000.0)
self.assertEqual(line51.closing_debit, 2000.0)
# 62.АВ пассивный: оборот Кт 2000, кон.сальдо Кт 2000.
line_av = wiz.line_ids.filtered(lambda l: l.code == '62.АВ')
self.assertEqual(line_av.turnover_credit, 2000.0)
self.assertEqual(line_av.closing_credit, 2000.0)
def test_opening_balance_from_prior_period(self):
"""Нач.сальдо периода = проводки до date_from."""
acc51 = self._acc('51')
acc62av = self._acc('62.АВ')
# Январь: аванс 500 (до периода).
self._post([
{'account_id': acc51.id, 'debit': 500.0},
{'account_id': acc62av.id, 'credit': 500.0,
'partner_id': self.partner_a.id},
], date='2026-01-20')
# Февраль: ещё 700.
self._post([
{'account_id': acc51.id, 'debit': 700.0},
{'account_id': acc62av.id, 'credit': 700.0,
'partner_id': self.partner_a.id},
], date='2026-02-15')
wiz = self.Wizard.create({
'date_from': '2026-02-01', 'date_to': '2026-02-28',
'company_id': self.company.id, 'hide_zero': True})
wiz.action_generate()
line51 = wiz.line_ids.filtered(lambda l: l.code == '51')
# Нач.сальдо Дт 500 (январь), оборот Дт 700, кон.сальдо Дт 1200.
self.assertEqual(line51.opening_debit, 500.0)
self.assertEqual(line51.turnover_debit, 700.0)
self.assertEqual(line51.closing_debit, 1200.0)
def test_hide_zero_excludes_empty_accounts(self):
"""Опция «скрывать нулевые» убирает счета без данных."""
acc51 = self._acc('51')
acc62av = self._acc('62.АВ')
self._post([
{'account_id': acc51.id, 'debit': 100.0},
{'account_id': acc62av.id, 'credit': 100.0,
'partner_id': self.partner_a.id},
], date='2026-02-10')
wiz = self.Wizard.create({
'date_from': '2026-02-01', 'date_to': '2026-02-28',
'company_id': self.company.id, 'hide_zero': True})
wiz.action_generate()
codes = set(wiz.line_ids.mapped('code'))
# Счёт 80 (уставный капитал) без проводок — не должен попасть.
self.assertNotIn('80', codes)
self.assertIn('51', codes)

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Журнал хозяйственных операций — в разделе «Операции». -->
<menuitem id="menu_rsbu_journal" name="Журнал хозяйственных операций"
parent="rsbu_chart_of_accounts.menu_rsbu_operations"
action="action_rsbu_move" sequence="10"
groups="group_rsbu_journal_user"/>
<!-- Оборотно-сальдовая ведомость — отдельный пункт верхнего уровня. -->
<menuitem id="menu_rsbu_trial_balance"
name="Оборотно-сальдовая ведомость"
parent="rsbu_chart_of_accounts.menu_rsbu_root"
action="action_rsbu_trial_balance_wizard" sequence="50"
groups="group_rsbu_journal_user"/>
</odoo>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_rsbu_move_list" model="ir.ui.view">
<field name="name">rsbu.move.list</field>
<field name="model">rsbu.move</field>
<field name="arch" type="xml">
<list string="Журнал операций РСБУ">
<field name="name"/>
<field name="date"/>
<field name="ref"/>
<field name="state" widget="badge"
decoration-success="state == 'posted'"
decoration-info="state == 'draft'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_rsbu_move_form" model="ir.ui.view">
<field name="name">rsbu.move.form</field>
<field name="model">rsbu.move</field>
<field name="arch" type="xml">
<form string="Операция РСБУ">
<header>
<button name="action_post" type="object" string="Провести"
class="btn-primary" invisible="state == 'posted'"/>
<button name="action_draft" type="object"
string="В черновик"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,posted"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="date"/>
<field name="ref"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<notebook>
<page string="Проводки">
<field name="line_ids">
<list editable="bottom">
<field name="account_id"/>
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"
optional="hide"/>
<field name="user_id" optional="hide"/>
<field name="employee_id"
optional="hide"/>
<field name="debit"/>
<field name="credit"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="total_debit"/>
<field name="total_credit"/>
</group>
</page>
<page string="Источник / Примечание">
<group>
<field name="source_model" readonly="1"/>
<field name="source_res_id" readonly="1"/>
</group>
<field name="note"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_rsbu_move_search" model="ir.ui.view">
<field name="name">rsbu.move.search</field>
<field name="model">rsbu.move</field>
<field name="arch" type="xml">
<search string="Поиск операций">
<field name="name"/>
<field name="ref"/>
<filter name="draft" string="Черновики"
domain="[('state', '=', 'draft')]"/>
<filter name="posted" string="Проведённые"
domain="[('state', '=', 'posted')]"/>
<separator/>
<filter name="group_state" string="Состояние"
context="{'group_by': 'state'}"/>
<filter name="group_date" string="Дата"
context="{'group_by': 'date'}"/>
</search>
</field>
</record>
<record id="action_rsbu_move" model="ir.actions.act_window">
<field name="name">Журнал операций РСБУ</field>
<field name="res_model">rsbu.move</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_rsbu_move_search"/>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import rsbu_trial_balance_wizard

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""Мастер формирования оборотно-сальдовой ведомости (ОСВ)."""
from datetime import timedelta
from odoo import api, fields, models
from odoo.tools import float_is_zero
class RsbuTrialBalanceWizard(models.TransientModel):
_name = 'rsbu.trial.balance.wizard'
_description = 'РСБУ — Мастер оборотно-сальдовой ведомости'
company_id = fields.Many2one(
'res.company', string='Компания', required=True,
default=lambda self: self.env.company)
date_from = fields.Date(
string='Период с', required=True,
default=lambda self: fields.Date.today().replace(month=1, day=1))
date_to = fields.Date(
string='Период по', required=True,
default=lambda self: fields.Date.today())
hide_zero = fields.Boolean(
string='Скрывать счета без данных', default=True,
help='Не показывать счета с нулевыми сальдо и оборотами.')
only_posted = fields.Boolean(
string='Только проведённые', default=True, readonly=True,
help='ОСВ строится по проведённым проводкам (иных в РСБУ-журнале '
'к учёту не принимается).')
line_ids = fields.One2many(
'rsbu.trial.balance.line', 'wizard_id', string='Строки ОСВ')
# ------------------------------------------------------------------
def _iter_accounts(self):
"""Счета компании для ОСВ (все, порядок по коду)."""
self.ensure_one()
return self.env['rsbu.account'].search([
('company_id', '=', self.company_id.id),
], order='complete_code, code')
def _account_values(self, account):
"""Шесть показателей ОСВ по счёту (развёрнутое сальдо для АП)."""
self.ensure_one()
cid = self.company_id.id
day_before = self.date_from - timedelta(days=1)
# Сальдо на начало (до date_from) — развёрнутое по субконто.
opening = account.get_expanded_balance(
date_to=day_before, company_id=cid)
# Обороты за период (по поддереву счёта).
turnover = account.get_turnover(
date_from=self.date_from, date_to=self.date_to,
company_id=cid, include_children=True)
# Сальдо на конец (до date_to включительно) — развёрнутое.
closing = account.get_expanded_balance(
date_to=self.date_to, company_id=cid)
return {
'opening_debit': opening['debit'],
'opening_credit': opening['credit'],
'turnover_debit': turnover['debit'],
'turnover_credit': turnover['credit'],
'closing_debit': closing['debit'],
'closing_credit': closing['credit'],
}
def _is_zero_row(self, vals):
rounding = self.company_id.currency_id.rounding or 0.01
return all(float_is_zero(vals[f], precision_rounding=rounding)
for f in ('opening_debit', 'opening_credit',
'turnover_debit', 'turnover_credit',
'closing_debit', 'closing_credit'))
def action_generate(self):
"""Пересчитать строки ОСВ и открыть отчёт на экране (QWeb-HTML)."""
self.ensure_one()
self.line_ids.unlink()
Line = self.env['rsbu.trial.balance.line']
currency = self.company_id.currency_id
vals_list = []
for account in self._iter_accounts():
vals = self._account_values(account)
if self.hide_zero and self._is_zero_row(vals):
continue
vals.update({
'wizard_id': self.id,
'account_id': account.id,
'code': account.code,
'name': account.name,
'level': account.level,
'is_group': bool(account.child_ids),
'parent_code': account.parent_id.code or False,
'company_id': self.company_id.id,
'currency_id': currency.id,
})
vals_list.append(vals)
Line.create(vals_list)
return self.action_view_report()
def action_view_report(self):
"""Открыть отчёт ОСВ (QWeb-HTML) для текущего мастера."""
self.ensure_one()
return self.env.ref(
'rsbu_journal.action_report_rsbu_trial_balance').report_action(self)
# ------------------------------------------------------------------
def _report_totals(self):
"""Итоги ОСВ — только по счетам верхнего уровня (без двойного счёта).
Итог считается по корневым счетам (level 0), чтобы не суммировать
родителя и его потомков дважды.
"""
self.ensure_one()
roots = self.line_ids.filtered(lambda l: not l.parent_code)
return {
'opening_debit': sum(roots.mapped('opening_debit')),
'opening_credit': sum(roots.mapped('opening_credit')),
'turnover_debit': sum(roots.mapped('turnover_debit')),
'turnover_credit': sum(roots.mapped('turnover_credit')),
'closing_debit': sum(roots.mapped('closing_debit')),
'closing_credit': sum(roots.mapped('closing_credit')),
}

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Мастер параметров ОСВ. -->
<record id="view_rsbu_trial_balance_wizard_form" model="ir.ui.view">
<field name="name">rsbu.trial.balance.wizard.form</field>
<field name="model">rsbu.trial.balance.wizard</field>
<field name="arch" type="xml">
<form string="Оборотно-сальдовая ведомость">
<group>
<group>
<field name="date_from"/>
<field name="date_to"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="hide_zero"/>
</group>
</group>
<footer>
<button name="action_generate" type="object"
string="Сформировать" class="btn-primary"/>
<button string="Отмена" special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="action_rsbu_trial_balance_wizard" model="ir.actions.act_window">
<field name="name">Оборотно-сальдовая ведомость</field>
<field name="res_model">rsbu.trial.balance.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Табличный просмотр строк ОСВ с drill-down по счёту. -->
<record id="view_rsbu_trial_balance_line_list" model="ir.ui.view">
<field name="name">rsbu.trial.balance.line.list</field>
<field name="model">rsbu.trial.balance.line</field>
<field name="arch" type="xml">
<list string="ОСВ" create="false" delete="false"
default_order="code">
<field name="code" string="Счёт"/>
<field name="name" string="Наименование"/>
<field name="opening_debit" sum="Сальдо нач. Дт"/>
<field name="opening_credit" sum="Сальдо нач. Кт"/>
<field name="turnover_debit" sum="Оборот Дт"/>
<field name="turnover_credit" sum="Оборот Кт"/>
<field name="closing_debit" sum="Сальдо кон. Дт"/>
<field name="closing_credit" sum="Сальдо кон. Кт"/>
<field name="currency_id" column_invisible="1"/>
<button name="action_open_entries" type="object"
string="Проводки" icon="fa-search"/>
</list>
</field>
</record>
</odoo>