Public release from ruodoo-project: 19.0 - 2026-05-31 21:19:12 UTC

This commit is contained in:
CI Publish Bot
2026-05-31 21:19:21 +00:00
commit aa4214c195
1213 changed files with 183945 additions and 0 deletions

View File

@ -0,0 +1,97 @@
============================
Шаблоны типовых операций
============================
.. |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:
Возможности
===========
* Создание многократно используемых шаблонов типовых операций
* Определение распределения по счетам с использованием процентов
* Применение шаблонов для создания проводок через визард
* Категоризация шаблонов с помощью признаков (тегов)
* Автоматическая проверка баланса шаблона (дебет = кредит)
* Поддержка двух типов строк:
* Продуктовая строка - заменяет счёт в существующих строках
* Строка оплаты - создаёт новые строки дебиторки/кредиторки
Настройка
=========
Для настройки модуля необходимо:
#. Перейти в *Бухгалтерия > Настройка > Шаблоны типовых операций*
#. Создать новый шаблон
#. Добавить строки шаблона с указанием:
* Счёт
* Сторона (Дебет/Кредит)
* Процент (0.01-100.00%)
* Тип строки:
* Продуктовая строка - заменяет счёт в существующих product-строках
* Строка оплаты - создаёт новые receivable/payable строки
#. При необходимости добавить признаки для категоризации
Модуль автоматически проверяет, что сумма процентов по дебету равна сумме процентов по кредиту.
Использование
=============
Для использования модуля:
#. Откройте любой документ, который наследует ``account.move.template.mixin``
#. Нажмите кнопку "Создать проводку по шаблону"
#. Выберите шаблон
#. Укажите базовую сумму
#. Нажмите "Создать проводку"
Модуль создаст новую проводку со счетами, распределёнными согласно процентам в шаблоне.
Отслеживание ошибок
===================
Ошибки отслеживаются на `GitHub Issues <https://github.com/OCA/account-financial-tools/issues>`_.
В случае проблем, пожалуйста, проверьте, не была ли ваша проблема уже зарегистрирована.
Авторы
======
Авторы
~~~~~~
* MK.Lab, RuOdoo
Участники
~~~~~~~~~
* 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,36 @@
{
'name': 'Accounting Journal Templates',
'version': '19.0.1.0.0',
'summary': 'Шаблоны типовых финансовых операций',
'description': """
Шаблоны типовых финансовых операций
====================================
Создание многократно используемых шаблонов типовых проводок с заранее
определённым распределением по счетам с использованием процентов.
Возможности:
------------
* Создание шаблонов с несколькими строками счетов
* Определение сторон (дебет/кредит) и процентов
* Автоматическая проверка баланса шаблона
* Применение шаблонов через визард для создания проводок
* Категоризация шаблонов с помощью признаков (тегов)
* Поддержка продуктовых строк и строк оплаты
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Accounting/Accounting',
'depends': ['account'],
'data': [
'security/ir.model.access.csv',
'data/demo_templates.xml',
'views/account_move_template_views.xml',
'views/account_move_template_wizard_views.xml',
],
'demo': [],
'installable': True,
'application': False,
'license': 'LGPL-3',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="demo_template_tag_warehouse" model="account.move.template.tag">
<field name="name">Склад</field>
<field name="color">1</field>
</record>
<record id="demo_template_tag_sales" model="account.move.template.tag">
<field name="name">Продажи</field>
<field name="color">2</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Теги шаблонов -->
<record id="demo_tag_general" model="account.move.template.tag">
<field name="name">Общие</field>
<field name="color">3</field>
</record>
<record id="demo_tag_tax" model="account.move.template.tag">
<field name="name">Налоги</field>
<field name="color">4</field>
</record>
<!-- Шаблон типовой операции: Начисление НДС -->
<!-- Примечание: account_id должны быть заменены на реальные счета из вашего плана счетов -->
<!-- Шаблон намеренно не создаётся здесь, т.к. требует конкретных счетов из плана счетов -->
</data>
</odoo>

View File

@ -0,0 +1,3 @@
from . import account_move_template
from . import account_move_template_mixin
from . import account_move_template_wizard

View File

@ -0,0 +1,94 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class AccountMoveTemplateTag(models.Model):
_name = 'account.move.template.tag'
_description = 'Признак шаблона'
name = fields.Char(string='Название', required=True, translate=True)
color = fields.Integer(string='Цвет')
class AccountMoveTemplate(models.Model):
_name = 'account.move.template'
_description = 'Шаблон типовой операции'
name = fields.Char(string='Название', required=True)
description = fields.Text(string='Описание')
tag_ids = fields.Many2many(
comodel_name='account.move.template.tag',
string='Признаки',
)
line_ids = fields.One2many(
comodel_name='account.move.template.line',
inverse_name='template_id',
string='Строки шаблона',
)
@api.constrains('line_ids')
def _check_balance(self):
for template in self:
if not template.line_ids:
raise ValidationError(
'Шаблон должен содержать хотя бы одну строку.'
)
debit_sum = sum(
line.percent for line in template.line_ids if line.move_type == 'debit'
)
credit_sum = sum(
line.percent for line in template.line_ids if line.move_type == 'credit'
)
if round(debit_sum, 2) != round(credit_sum, 2):
diff = round(abs(debit_sum - credit_sum), 2)
raise ValidationError(
f'Сумма дебета ({debit_sum:.2f}%) не равна сумме кредита '
f'({credit_sum:.2f}%), разница: {diff:.2f}%'
)
class AccountMoveTemplateLine(models.Model):
_name = 'account.move.template.line'
_description = 'Строка шаблона типовой операции'
template_id = fields.Many2one(
comodel_name='account.move.template',
string='Шаблон',
required=True,
ondelete='cascade',
)
account_id = fields.Many2one(
comodel_name='account.account',
string='Счёт',
required=True,
ondelete='restrict',
)
move_type = fields.Selection(
selection=[('debit', 'Дебет'), ('credit', 'Кредит')],
string='Сторона',
required=True,
)
percent = fields.Float(
string='Процент',
required=True,
digits=(5, 2),
)
line_type = fields.Selection(
selection=[
('product', 'Продуктовая строка (заменяет счёт)'),
('payment', 'Строка оплаты (receivable/payable)')
],
string='Тип строки',
required=True,
default='product',
help='Продуктовая строка заменяет счёт в существующих product-строках инвойса. '
'Строка оплаты создаёт новую payment_term строку.'
)
@api.constrains('percent')
def _check_percent(self):
for line in self:
if line.percent < 0.01 or line.percent > 100.0:
raise ValidationError(
'Процент должен быть от 0.01 до 100.00.'
)

View File

@ -0,0 +1,57 @@
from odoo import api, fields, models
class AccountMoveTemplateMixin(models.AbstractModel):
_name = 'account.move.template.mixin'
_description = 'Миксин для создания проводок через шаблоны'
move_ids = fields.Many2many(
comodel_name='account.move',
string='Проводки',
)
move_count = fields.Integer(
compute='_compute_move_count',
string='Количество проводок',
)
@api.depends('move_ids')
def _compute_move_count(self):
for record in self:
record.move_count = len(record.move_ids)
def action_open_journal_wizard(self):
"""If move_ids is empty, open wizard. Otherwise delegate to action_view_moves."""
self.ensure_one()
if self.move_ids:
return self.action_view_moves()
amounts = self.get_move_line_amounts()
default_amount = amounts[0]['amount'] if amounts else 0.0
return {
'type': 'ir.actions.act_window',
'name': 'Создать проводку',
'res_model': 'account.move.template.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_res_model': self._name,
'default_res_id': self.id,
'default_amount': default_amount,
},
}
def action_view_moves(self):
"""Return action to view related account.move records."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Проводки',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', self.move_ids.ids)],
}
def get_move_line_amounts(self):
"""Override in inheriting models to provide line amounts.
Returns list of dicts: [{'name': str, 'amount': float}, ...]
"""
return []

View File

@ -0,0 +1,103 @@
from odoo import api, fields, models
from odoo.exceptions import UserError
class AccountMoveTemplateWizardLine(models.TransientModel):
_name = 'account.move.template.wizard.line'
_description = 'Черновая строка визарда шаблона'
wizard_id = fields.Many2one(
comodel_name='account.move.template.wizard',
required=True,
ondelete='cascade',
)
account_id = fields.Many2one(
comodel_name='account.account',
string='Счёт',
required=True,
)
move_type = fields.Selection(
selection=[('debit', 'Дебет'), ('credit', 'Кредит')],
string='Сторона',
required=True,
)
amount = fields.Float(
string='Сумма',
digits=(16, 2),
)
class AccountMoveTemplateWizard(models.TransientModel):
_name = 'account.move.template.wizard'
_description = 'Визард создания проводки по шаблону'
res_model = fields.Char(string='Модель документа', required=True)
res_id = fields.Integer(string='ID документа', required=True)
template_id = fields.Many2one(
comodel_name='account.move.template',
string='Шаблон',
)
amount = fields.Float(
string='Базовая сумма',
digits=(16, 2),
)
draft_line_ids = fields.One2many(
comodel_name='account.move.template.wizard.line',
inverse_name='wizard_id',
string='Черновые строки',
)
@api.onchange('template_id', 'amount')
def _onchange_compute_draft_lines(self):
"""Recompute draft lines when template or amount changes."""
self.draft_line_ids = [(5, 0, 0)] # clear existing
if not self.template_id or not self.amount:
return
lines = []
for tpl_line in self.template_id.line_ids:
line_amount = self.amount * tpl_line.percent / 100.0
lines.append((0, 0, {
'account_id': tpl_line.account_id.id,
'move_type': tpl_line.move_type,
'amount': line_amount,
}))
self.draft_line_ids = lines
def action_post(self):
"""Create account.move from draft lines and link to source document."""
self.ensure_one()
if not self.template_id or not self.amount:
raise UserError('Необходимо выбрать шаблон и указать базовую сумму.')
try:
# Build move lines
move_line_vals = []
for draft_line in self.draft_line_ids:
if draft_line.move_type == 'debit':
debit = draft_line.amount
credit = 0.0
else:
debit = 0.0
credit = draft_line.amount
move_line_vals.append((0, 0, {
'account_id': draft_line.account_id.id,
'debit': debit,
'credit': credit,
'name': self.template_id.name,
}))
# Create the move
move = self.env['account.move'].create({
'move_type': 'entry',
'state': 'draft',
'line_ids': move_line_vals,
})
# Link move to source document via move_ids
source_record = self.env[self.res_model].browse(self.res_id)
source_record.move_ids = [(4, move.id)]
except Exception as e:
raise UserError(f'Ошибка при создании проводки: {str(e)}') from e
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_move_template_user,account.move.template user,model_account_move_template,account.group_account_user,1,1,1,1
access_account_move_template_line_user,account.move.template.line user,model_account_move_template_line,account.group_account_user,1,1,1,1
access_account_move_template_tag_user,account.move.template.tag user,model_account_move_template_tag,account.group_account_user,1,1,1,1
access_account_move_template_wizard_user,account.move.template.wizard user,model_account_move_template_wizard,account.group_account_user,1,1,1,1
access_account_move_template_wizard_line_user,account.move.template.wizard.line user,model_account_move_template_wizard_line,account.group_account_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_move_template_user account.move.template user model_account_move_template account.group_account_user 1 1 1 1
3 access_account_move_template_line_user account.move.template.line user model_account_move_template_line account.group_account_user 1 1 1 1
4 access_account_move_template_tag_user account.move.template.tag user model_account_move_template_tag account.group_account_user 1 1 1 1
5 access_account_move_template_wizard_user account.move.template.wizard user model_account_move_template_wizard account.group_account_user 1 1 1 1
6 access_account_move_template_wizard_line_user account.move.template.wizard.line user model_account_move_template_wizard_line account.group_account_user 1 1 1 1

View File

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

View File

@ -0,0 +1,200 @@
"""
Tests for Template_Engine (account_move_templates).
Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5
"""
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_account(env, account_type='asset_current'):
"""Return an existing account of the given type, or create one."""
account = env['account.account'].search(
[('account_type', '=', account_type)], limit=1
)
if not account:
account = env['account.account'].create({
'name': f'Test Account ({account_type})',
'code': f'TST{account_type[:4].upper()}',
'account_type': account_type,
})
return account
def _make_template(env, name='Test Template', lines=None):
"""Create an AccountMoveTemplate with the given lines.
lines: list of dicts with keys: account_id, move_type, percent, line_type
"""
vals = {'name': name}
if lines is not None:
vals['line_ids'] = [(0, 0, line) for line in lines]
return env['account.move.template'].create(vals)
# ---------------------------------------------------------------------------
# TestMoveTemplateValidation
# ---------------------------------------------------------------------------
class TestMoveTemplateValidation(TransactionCase):
"""Validates: Requirements 3.1, 3.2, 3.3, 3.4"""
def setUp(self):
super().setUp()
self.account_debit = _get_account(self.env, 'asset_current')
self.account_credit = _get_account(self.env, 'liability_current')
def _line(self, move_type, percent, account=None):
if account is None:
account = self.account_debit if move_type == 'debit' else self.account_credit
return {
'account_id': account.id,
'move_type': move_type,
'percent': percent,
'line_type': 'product',
}
def test_no_lines_raises_validation_error(self):
"""Req 3.1 — template without lines raises ValidationError."""
with self.assertRaises(ValidationError):
_make_template(self.env, name='Empty Template', lines=[])
def test_imbalanced_percentages_raises_validation_error(self):
"""Req 3.2 — debit sum != credit sum raises ValidationError."""
with self.assertRaises(ValidationError):
_make_template(self.env, name='Imbalanced', lines=[
self._line('debit', 60.0),
self._line('credit', 40.0),
])
def test_balanced_percentages_saves_without_error(self):
"""Req 3.3 — debit sum == credit sum saves without error."""
template = _make_template(self.env, name='Balanced', lines=[
self._line('debit', 100.0),
self._line('credit', 100.0),
])
self.assertTrue(template.id, "Template should be saved with a valid ID")
def test_percent_below_minimum_raises_validation_error(self):
"""Req 3.4 — percent < 0.01 raises ValidationError."""
with self.assertRaises(ValidationError):
_make_template(self.env, name='Low Percent', lines=[
self._line('debit', 0.001),
self._line('credit', 0.001),
])
def test_percent_above_maximum_raises_validation_error(self):
"""Req 3.4 — percent > 100.0 raises ValidationError."""
with self.assertRaises(ValidationError):
_make_template(self.env, name='High Percent', lines=[
self._line('debit', 101.0),
self._line('credit', 101.0),
])
# ---------------------------------------------------------------------------
# TestMoveTemplateWizard
# ---------------------------------------------------------------------------
class TestMoveTemplateWizard(TransactionCase):
"""Validates: Requirement 3.5"""
def setUp(self):
super().setUp()
self.account_debit = _get_account(self.env, 'asset_current')
self.account_credit = _get_account(self.env, 'liability_current')
# Create a balanced template: 100% debit / 100% credit
self.template = self.env['account.move.template'].create({
'name': 'Wizard Test Template',
'line_ids': [
(0, 0, {
'account_id': self.account_debit.id,
'move_type': 'debit',
'percent': 100.0,
'line_type': 'product',
}),
(0, 0, {
'account_id': self.account_credit.id,
'move_type': 'credit',
'percent': 100.0,
'line_type': 'product',
}),
],
})
# Create a source document that uses the mixin (account.move itself
# does not use the mixin, so we create a minimal move to act as source)
self.source_move = self.env['account.move'].create({
'move_type': 'entry',
'state': 'draft',
'line_ids': [
(0, 0, {
'account_id': self.account_debit.id,
'debit': 0.0,
'credit': 0.0,
'name': 'Source line',
}),
],
})
def test_apply_template_creates_draft_move_with_correct_distribution(self):
"""Req 3.5 — applying template via wizard creates a draft account.move
with correct account distribution."""
base_amount = 1000.0
wizard = self.env['account.move.template.wizard'].create({
'res_model': 'account.move',
'res_id': self.source_move.id,
'template_id': self.template.id,
'amount': base_amount,
})
# Trigger onchange to populate draft lines
wizard._onchange_compute_draft_lines()
# Verify draft lines were computed
self.assertEqual(len(wizard.draft_line_ids), 2,
"Wizard should have 2 draft lines (one debit, one credit)")
debit_line = wizard.draft_line_ids.filtered(lambda l: l.move_type == 'debit')
credit_line = wizard.draft_line_ids.filtered(lambda l: l.move_type == 'credit')
self.assertAlmostEqual(debit_line.amount, base_amount,
msg="Debit line amount should equal base_amount * 100%")
self.assertAlmostEqual(credit_line.amount, base_amount,
msg="Credit line amount should equal base_amount * 100%")
# Post the wizard — creates the account.move
wizard.action_post()
# Reload source move and check linked moves
self.source_move.invalidate_recordset()
linked_moves = self.source_move.move_ids
self.assertTrue(linked_moves, "A new account.move should be linked to the source")
new_move = linked_moves[-1]
self.assertEqual(new_move.state, 'draft',
"Created move should be in draft state")
move_lines = new_move.line_ids
debit_move_line = move_lines.filtered(lambda l: l.debit > 0)
credit_move_line = move_lines.filtered(lambda l: l.credit > 0)
self.assertTrue(debit_move_line, "Move should have a debit line")
self.assertTrue(credit_move_line, "Move should have a credit line")
self.assertAlmostEqual(debit_move_line[0].debit, base_amount,
msg="Debit amount on move line should match base_amount")
self.assertAlmostEqual(credit_move_line[0].credit, base_amount,
msg="Credit amount on move line should match base_amount")
self.assertEqual(debit_move_line[0].account_id, self.account_debit,
"Debit line should use the debit account from template")
self.assertEqual(credit_move_line[0].account_id, self.account_credit,
"Credit line should use the credit account from template")

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- List view -->
<record id="view_account_move_template_tree" model="ir.ui.view">
<field name="name">account.move.template.list</field>
<field name="model">account.move.template</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="description"/>
<field name="tag_ids" widget="many2many_tags"/>
</list>
</field>
</record>
<!-- Form view -->
<record id="view_account_move_template_form" model="ir.ui.view">
<field name="name">account.move.template.form</field>
<field name="model">account.move.template</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="Название шаблона"/>
</h1>
</div>
<group>
<field name="description"/>
<field name="tag_ids" widget="many2many_tags"/>
</group>
<notebook>
<page string="Строки шаблона" name="lines">
<field name="line_ids">
<list editable="bottom">
<field name="account_id"/>
<field name="move_type"/>
<field name="percent"/>
<field name="line_type"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search view -->
<record id="view_account_move_template_search" model="ir.ui.view">
<field name="name">account.move.template.search</field>
<field name="model">account.move.template</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Название"/>
<field name="tag_ids" string="Признак"/>
<filter name="group_by_tag" string="По признаку" context="{'group_by': 'tag_ids'}"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_account_move_template" model="ir.actions.act_window">
<field name="name">Шаблоны типовых операций</field>
<field name="res_model">account.move.template</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_account_move_template_search"/>
</record>
<!-- Menu item under Accounting → Configuration -->
<menuitem
id="menu_account_move_template"
name="Шаблоны типовых операций"
parent="account.menu_finance_configuration"
action="action_account_move_template"
sequence="100"/>
</data>
</odoo>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_move_template_wizard_form" model="ir.ui.view">
<field name="name">account.move.template.wizard.form</field>
<field name="model">account.move.template.wizard</field>
<field name="arch" type="xml">
<form string="Создать проводку по шаблону">
<group>
<field name="template_id"/>
<field name="amount"/>
</group>
<field name="draft_line_ids" readonly="1">
<list>
<field name="account_id"/>
<field name="move_type"/>
<field name="amount"/>
</list>
</field>
<footer>
<button name="action_post" type="object" string="Провести"
class="btn-primary"
invisible="not template_id or amount &lt;= 0"/>
<button string="Отмена" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>