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

58
rsbu_connector/README.rst Normal file
View File

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

View File

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

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
{
'name': 'РСБУ — Коннектор документов',
'version': '19.0.2026.09.17',
'summary': 'Миксин-коннектор: генерация РСБУ-проводок по проведению документов',
'description': """
РСБУ — Коннектор документов
===========================
Автоматически формирует бухгалтерские проводки РСБУ при проведении
документов Odoo (счетов и платежей). Пользователь работает как обычно —
проводит счёт или регистрирует оплату, а параллельный слой РСБУ создаёт
соответствующую запись в журнале хозяйственных операций.
Возможности:
------------
* Проводка РСБУ создаётся в момент подтверждения документа.
* При отмене или возврате документа в черновик проводка удаляется —
журнал РСБУ всегда соответствует состоянию документов.
* Из документа можно перейти к связанной проводке РСБУ и обратно.
Правила формирования проводок (какие счета дебетуются и кредитуются)
задаются в модуле шаблонов операций. Сам коннектор отвечает только за
своевременное и корректное создание записей.
Нативная бухгалтерия Odoo не затрагивается.
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Accounting/Localizations',
'depends': ['rsbu_journal', 'account'],
'data': [
'views/account_move_views.xml',
'views/account_payment_views.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@@ -0,0 +1,3 @@
from . import rsbu_posting_mixin
from . import account_move
from . import account_payment

View File

@@ -0,0 +1,20 @@
from odoo import models
class AccountMove(models.Model):
_name = 'account.move'
_inherit = ['account.move', 'rsbu.posting.mixin']
def action_post(self):
res = super().action_post()
for move in self.filtered(lambda m: m.state == 'posted'):
move._rsbu_generate()
return res
def button_draft(self):
self._rsbu_remove()
return super().button_draft()
def button_cancel(self):
self._rsbu_remove()
return super().button_cancel()

View File

@@ -0,0 +1,21 @@
from odoo import models
class AccountPayment(models.Model):
_name = 'account.payment'
_inherit = ['account.payment', 'rsbu.posting.mixin']
def action_post(self):
res = super().action_post()
# После проведения платёж переходит в in_process или paid.
for payment in self.filtered(lambda p: p.state in ('in_process', 'paid')):
payment._rsbu_generate()
return res
def action_draft(self):
self._rsbu_remove()
return super().action_draft()
def action_cancel(self):
self._rsbu_remove()
return super().action_cancel()

View File

@@ -0,0 +1,130 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class RsbuPostingMixin(models.AbstractModel):
"""Абстрактный миксин связи документа-источника с журналом РСБУ.
Инкапсулирует механику: идемпотентную генерацию РСБУ-операции,
зеркалирование отмены, транзакционную блокировку и навигацию.
Расчёт строк Дт/Кт делегируется методу _rsbu_build_lines (Этап 4);
на Этапе 3 это заглушка, возвращающая пустой список.
"""
_name = 'rsbu.posting.mixin'
_description = 'РСБУ — Миксин проведения'
rsbu_move_id = fields.Many2one(
'rsbu.move', string='РСБУ-проводка', copy=False, readonly=True)
rsbu_move_count = fields.Integer(
string='РСБУ-проводки', compute='_compute_rsbu_move_count')
@api.depends('rsbu_move_id')
def _compute_rsbu_move_count(self):
for rec in self:
rec.rsbu_move_count = 1 if rec.rsbu_move_id else 0
# ------------------------------------------------------------------
# Точки расширения (реализуются Этапом 4)
# ------------------------------------------------------------------
def _rsbu_is_applicable(self):
"""Входит ли документ в РСБУ-периметр (нужна ли РСБУ-проводка).
На Этапе 3 маппинга ещё нет, поэтому по умолчанию False —
штатное проведение документов Odoo не затрагивается. На Этапе 4
применимость определяется матрицей связности (наличием шаблона).
"""
self.ensure_one()
return False
def _rsbu_get_move_values(self):
"""Значения шапки rsbu.move для документа-источника."""
self.ensure_one()
return {
'date': self._rsbu_move_date(),
'company_id': self.company_id.id,
'ref': self.display_name,
'source_model': self._name,
'source_res_id': self.id,
}
def _rsbu_move_date(self):
"""Дата РСБУ-операции. По умолчанию — поле date документа/сегодня."""
self.ensure_one()
return getattr(self, 'date', False) or fields.Date.context_today(self)
def _rsbu_build_lines(self):
"""Список значений строк rsbu.move.line (Дт/Кт + субконто).
ЗАГЛУШКА Этапа 3: возвращает пустой список -> трактуется как
«нет маппинга». Реализуется маппингом на Этапе 4.
"""
self.ensure_one()
return []
# ------------------------------------------------------------------
# Механика
# ------------------------------------------------------------------
def _rsbu_generate(self):
"""Идемпотентно создать и провести РСБУ-операцию по документу.
Управляемый обход: при context ``rsbu_skip=True`` генерация
пропускается (по аналогии с with_user/with_context Odoo) —
для служебных/внешних вызовов, где РСБУ-слой не нужен.
"""
self.ensure_one()
if self.env.context.get('rsbu_skip'):
return
if not self._rsbu_is_applicable() or self.rsbu_move_id:
return
lines = self._rsbu_build_lines()
if not lines:
raise UserError(_(
'Не удалось сформировать РСБУ-проводку для «%s»: '
'не определён маппинг типовой операции.')
% self.display_name)
vals = self._rsbu_get_move_values()
# Служебный маркер резолвера: строки с origin_self=True ссылаются на
# саму создаваемую проводку как на документ-основание (аванс).
# Убираем маркер из vals строк (ORM его не знает) и запоминаем позиции.
origin_self_idx = []
clean_lines = []
for i, line in enumerate(lines):
line = dict(line)
if line.pop('origin_self', False):
origin_self_idx.append(i)
clean_lines.append(line)
vals['line_ids'] = [(0, 0, line) for line in clean_lines]
# sudo: РСБУ-проводка — внутренняя механика, создаётся при проведении
# документа независимо от прав пользователя на РСБУ-модели.
move = self.env['rsbu.move'].sudo().create(vals)
# Проставить origin_move_id = сама проводка для помеченных строк.
if origin_self_idx:
move_lines = move.line_ids
for i in origin_self_idx:
if i < len(move_lines):
move_lines[i].origin_move_id = move.id
move.action_post() # проверки баланса/субконто Этапа 2
self.sudo().rsbu_move_id = move.id
def _rsbu_remove(self):
"""Зеркалирование отмены: удалить связанную РСБУ-операцию."""
for rec in self:
move = rec.rsbu_move_id.sudo()
if move:
rec.sudo().rsbu_move_id = False
if move.state == 'posted':
move.action_draft()
move.unlink()
# ------------------------------------------------------------------
# Навигация
# ------------------------------------------------------------------
def action_view_rsbu_move(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('РСБУ-проводка'),
'res_model': 'rsbu.move',
'view_mode': 'form',
'res_id': self.rsbu_move_id.id,
}

View File

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

View File

@@ -0,0 +1,120 @@
from unittest.mock import patch
from odoo.tests import TransactionCase, tagged
from odoo.exceptions import UserError
@tagged('post_install', '-at_install')
class TestRsbuConnector(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.partner = cls.env['res.partner'].create({'name': 'Cn Клиент'})
cls.acc_51 = cls._acc(cls, '51')
cls.acc_62av = cls._acc(cls, '62.АВ')
# Простой входящий платёж.
cls.journal_bank = cls.env['account.journal'].search(
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
limit=1)
def _acc(self, code):
return self.env['rsbu.account'].search([
('code', '=', code),
('company_id', '=', self.env.company.id)], limit=1)
def _balanced_lines(self):
"""Тестовый маппинг: Дт 51 — Кт 62.АВ на 1000."""
return [
{'account_id': self.acc_51.id, 'debit': 1000.0, 'credit': 0.0},
{'account_id': self.acc_62av.id, 'debit': 0.0, 'credit': 1000.0,
'partner_id': self.partner.id},
]
def _patch_applicable(self):
"""Сделать документ применимым к РСБУ.
Патчим методы на актуальном классе account.payment (в его MRO),
чтобы патч работал независимо от того, переопределил ли их
rsbu_mapping.
"""
cls = type(self.env['account.payment'])
return patch.object(cls, '_rsbu_is_applicable',
lambda self: True)
def _patch_build_lines(self, lines):
cls = type(self.env['account.payment'])
return patch.object(cls, '_rsbu_build_lines',
lambda self: lines)
def _make_payment(self):
return self.env['account.payment'].create({
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': self.partner.id,
'amount': 1000.0,
'journal_id': self.journal_bank.id,
})
def test_generate_on_post(self):
pay = self._make_payment()
with self._patch_applicable(), \
self._patch_build_lines(self._balanced_lines()):
pay.action_post()
self.assertTrue(pay.rsbu_move_id)
self.assertEqual(pay.rsbu_move_id.state, 'posted')
self.assertEqual(pay.rsbu_move_id.source_model, 'account.payment')
self.assertEqual(pay.rsbu_move_id.source_res_id, pay.id)
self.assertEqual(pay.rsbu_move_count, 1)
def test_not_applicable_no_generation(self):
# По умолчанию (Этап 3) документ не в РСБУ-периметре — проводится штатно.
pay = self._make_payment()
pay.action_post()
self.assertFalse(pay.rsbu_move_id)
def test_rsbu_skip_context(self):
# Явный обход через контекст даже при применимости.
pay = self._make_payment()
with self._patch_applicable(), \
self._patch_build_lines(self._balanced_lines()):
pay.with_context(rsbu_skip=True).action_post()
self.assertFalse(pay.rsbu_move_id)
def test_no_mapping_blocks_post(self):
# Документ применим, но маппинг не дал строк -> блок.
pay = self._make_payment()
with self._patch_applicable(), self.assertRaises(UserError):
pay.action_post()
def test_idempotent_double_post(self):
pay = self._make_payment()
with self._patch_applicable(), \
self._patch_build_lines(self._balanced_lines()):
pay.action_post()
first = pay.rsbu_move_id
pay._rsbu_generate()
self.assertEqual(pay.rsbu_move_id, first)
def test_mirror_on_cancel(self):
pay = self._make_payment()
with self._patch_applicable(), \
self._patch_build_lines(self._balanced_lines()):
pay.action_post()
move = pay.rsbu_move_id
self.assertTrue(move.exists())
pay.action_cancel()
self.assertFalse(pay.rsbu_move_id)
self.assertFalse(move.exists())
def test_mirror_then_repost_creates_new(self):
pay = self._make_payment()
with self._patch_applicable(), \
self._patch_build_lines(self._balanced_lines()):
pay.action_post()
first_id = pay.rsbu_move_id.id
pay.action_draft()
self.assertFalse(pay.rsbu_move_id)
pay.action_post()
self.assertTrue(pay.rsbu_move_id)
self.assertNotEqual(pay.rsbu_move_id.id, first_id)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_move_form_rsbu" model="ir.ui.view">
<field name="name">account.move.form.rsbu</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="action_view_rsbu_move" type="object"
class="oe_stat_button" icon="fa-book"
invisible="rsbu_move_count == 0">
<field name="rsbu_move_count" widget="statinfo"
string="РСБУ"/>
</button>
</div>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_payment_form_rsbu" model="ir.ui.view">
<field name="name">account.payment.form.rsbu</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="action_view_rsbu_move" type="object"
class="oe_stat_button" icon="fa-book"
invisible="rsbu_move_count == 0">
<field name="rsbu_move_count" widget="statinfo"
string="РСБУ"/>
</button>
</div>
</field>
</record>
</odoo>