Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
58
rsbu_connector/README.rst
Normal file
58
rsbu_connector/README.rst
Normal 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
|
||||
|
||||
Модуль является частью РСБУ-слоя проекта РуОду.
|
||||
1
rsbu_connector/__init__.py
Normal file
1
rsbu_connector/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
40
rsbu_connector/__manifest__.py
Normal file
40
rsbu_connector/__manifest__.py
Normal 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'],
|
||||
}
|
||||
3
rsbu_connector/models/__init__.py
Normal file
3
rsbu_connector/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import rsbu_posting_mixin
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
20
rsbu_connector/models/account_move.py
Normal file
20
rsbu_connector/models/account_move.py
Normal 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()
|
||||
21
rsbu_connector/models/account_payment.py
Normal file
21
rsbu_connector/models/account_payment.py
Normal 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()
|
||||
130
rsbu_connector/models/rsbu_posting_mixin.py
Normal file
130
rsbu_connector/models/rsbu_posting_mixin.py
Normal 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,
|
||||
}
|
||||
1
rsbu_connector/tests/__init__.py
Normal file
1
rsbu_connector/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_connector
|
||||
120
rsbu_connector/tests/test_connector.py
Normal file
120
rsbu_connector/tests/test_connector.py
Normal 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)
|
||||
18
rsbu_connector/views/account_move_views.xml
Normal file
18
rsbu_connector/views/account_move_views.xml
Normal 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>
|
||||
18
rsbu_connector/views/account_payment_views.xml
Normal file
18
rsbu_connector/views/account_payment_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user