Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
50
rsbu_mapping/DEVELOPER.md
Normal file
50
rsbu_mapping/DEVELOPER.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# РСБУ — Шаблоны типовых операций (маппинг)
|
||||
|
||||
Гибридный маппинг: декларативные шаблоны операций + матрица связности
|
||||
(данные в БД) и Python-резолверы в коде модуля (`resolvers/`).
|
||||
|
||||
## Резолверы коммерческого цикла
|
||||
|
||||
| Резолвер | Операция | Проводки |
|
||||
| --- | --- | --- |
|
||||
| `advance_received` | получение аванса от покупателя | Дт 51 — Кт 62.АВ; Дт 76.АВ — Кт 68 |
|
||||
| `advance_refund` | возврат аванса покупателю | Дт 62.АВ — Кт 51; Дт 68 — Кт 76.АВ |
|
||||
| `shipment_upd` | отгрузка / УПД (+зачёт аванса) | Дт 62.01 — Кт 90.01; Дт 90.03 — Кт 68; Дт 62.АВ — Кт 62.01; Дт 68 — Кт 76.АВ |
|
||||
| `sale_credit_note` | возврат от клиента | сторно реализации + НДС |
|
||||
| `payment_after_shipment` | постоплата | Дт 51 — Кт 62.01 |
|
||||
| `purchase_bill` | счёт поставщика | Дт 44/expense — Кт 60 |
|
||||
| `purchase_credit_note` | возврат поставщику | Дт 60 — Кт 44/expense |
|
||||
| `supplier_payment` | оплата поставщику | Дт 60 — Кт 51 |
|
||||
| `supplier_refund` | возврат от поставщика | Дт 51 — Кт 60 |
|
||||
|
||||
Реализация выручки — по **брутто-схеме** (90-1 с НДС, НДС отдельно на 90-3),
|
||||
как в типовой 1С и по Инструкции 94н (см. `docs/research/09`).
|
||||
|
||||
Зачёт аванса при отгрузке определяется по **кредитовому остатку 62.АВ у
|
||||
контрагента** в РСБУ-журнале (`advance.get_balance(subconto={'partner': id})`),
|
||||
сумма зачёта = min(остаток аванса, сумма реализации); авансовый НДС
|
||||
списывается пропорционально зачтённой части (ограничен остатком 76.АВ).
|
||||
|
||||
## Осознанные ограничения (для УСН)
|
||||
|
||||
- **Входящий НДС не выделяется (Р3).** В `purchase_bill` полная сумма счёта
|
||||
поставщика (с НДС) относится в затраты (`Дт 44 — Кт 60`). На спецрежиме
|
||||
УСН (ставки 5%/7% без права на вычет) это **корректно**: входящий НДС к
|
||||
вычету не принимается и включается в стоимость. Выделение НДС на счёт 19
|
||||
и вычет `Дт 68 — Кт 19` относятся к общему режиму (ОСНО) и вне области
|
||||
проекта.
|
||||
|
||||
- **Себестоимость не списывается (Р2).** Проводка `Дт 90.02 — Кт 41/44`
|
||||
не формируется: для целей УСН учёт себестоимости не требуется (на
|
||||
«Доходы» не влияет на налог; на «Доходы−Расходы» расход признаётся
|
||||
кассово). При необходимости ведётся отдельной ручной операцией.
|
||||
|
||||
- **Субконто на денежных строках (Р4).** Строки по денежным счетам (51/50)
|
||||
формируются без субконто-партнёра (в плане 94н у этих счетов субконто не
|
||||
обязательно). При необходимости отчётности по движению денежных средств
|
||||
партнёра на денежной строке можно добавить — это опция, не ограничение.
|
||||
|
||||
> Нормативные параметры сверяются со специалистом (см.
|
||||
> `docs/research/00-index.md`). Резолверы опираются на регламент
|
||||
> `docs/research/08-business-process-usn.md` и справочник корреспонденции
|
||||
> `docs/research/09-account-correspondence.md`.
|
||||
65
rsbu_mapping/README.rst
Normal file
65
rsbu_mapping/README.rst
Normal file
@@ -0,0 +1,65 @@
|
||||
===============================
|
||||
РСБУ — Шаблоны типовых операций
|
||||
===============================
|
||||
|
||||
.. |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
|
||||
|
||||
Модуль является частью РСБУ-слоя проекта РуОду.
|
||||
2
rsbu_mapping/__init__.py
Normal file
2
rsbu_mapping/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import resolvers
|
||||
from . import models
|
||||
43
rsbu_mapping/__manifest__.py
Normal file
43
rsbu_mapping/__manifest__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'РСБУ — Шаблоны типовых операций (маппинг)',
|
||||
'version': '19.0.2026.09.17',
|
||||
'summary': 'Гибридный маппинг: шаблоны + матрица связности + резолверы',
|
||||
'description': """
|
||||
РСБУ — Шаблоны типовых операций
|
||||
===============================
|
||||
|
||||
Задаёт правила, по которым документы Odoo превращаются в проводки РСБУ:
|
||||
какие счета дебетуются и кредитуются для каждой хозяйственной операции.
|
||||
|
||||
Возможности:
|
||||
------------
|
||||
* Шаблоны операций с указанием ролей счетов (дебет / кредит).
|
||||
* Матрица связности, которая подбирает нужный шаблон по типу документа
|
||||
и параметрам операции.
|
||||
* Поддержка полного коммерческого цикла: получение аванса (в т. ч. НДС
|
||||
с аванса), возврат аванса, реализация/отгрузка (выручка, НДС, зачёт
|
||||
ранее полученного аванса), постоплата.
|
||||
|
||||
Как пользоваться:
|
||||
-----------------
|
||||
Настройте шаблоны операций и правила их подбора (или воспользуйтесь
|
||||
готовым набором из модуля демо-шаблонов). Далее при проведении
|
||||
документов проводки РСБУ формируются автоматически по этим правилам.
|
||||
""",
|
||||
'author': 'MK.Lab, RuOdoo',
|
||||
'website': 'https://ruodoo.ru',
|
||||
'category': 'Accounting/Localizations',
|
||||
'depends': ['rsbu_connector', 'account'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/rsbu_operation_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_payment_views.xml',
|
||||
'views/rsbu_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'maintainers': ['mklab', 'ruodoo'],
|
||||
}
|
||||
4
rsbu_mapping/models/__init__.py
Normal file
4
rsbu_mapping/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import rsbu_operation_template
|
||||
from . import rsbu_operation_matrix
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
68
rsbu_mapping/models/account_move.py
Normal file
68
rsbu_mapping/models/account_move.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from odoo import api, fields, models
|
||||
from ..resolvers.registry import get_resolver
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
rsbu_template_id = fields.Many2one(
|
||||
'rsbu.operation.template', string='Шаблон РСБУ',
|
||||
compute='_compute_rsbu_template_id', store=True, readonly=False,
|
||||
copy=False)
|
||||
|
||||
@api.depends('move_type', 'state', 'line_ids')
|
||||
def _compute_rsbu_template_id(self):
|
||||
for move in self:
|
||||
if move.rsbu_template_id:
|
||||
# ручное переопределение сохраняем
|
||||
continue
|
||||
move.rsbu_template_id = move._rsbu_detect_template()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Классификация
|
||||
# ------------------------------------------------------------------
|
||||
def _rsbu_doc_kind(self):
|
||||
self.ensure_one()
|
||||
return self.move_type
|
||||
|
||||
def _rsbu_has_advance(self):
|
||||
self.ensure_one()
|
||||
return False
|
||||
|
||||
def _rsbu_partner_kind(self):
|
||||
self.ensure_one()
|
||||
if self.move_type in ('out_invoice', 'out_refund'):
|
||||
return 'customer'
|
||||
if self.move_type in ('in_invoice', 'in_refund'):
|
||||
return 'supplier'
|
||||
return 'any'
|
||||
|
||||
def _rsbu_detect_template(self):
|
||||
self.ensure_one()
|
||||
# sudo: детект — внутренняя механика; пользователь, проводящий
|
||||
# документ, может не иметь прав на РСБУ-модели.
|
||||
Matrix = self.env['rsbu.operation.matrix'].sudo()
|
||||
rules = Matrix.search([
|
||||
('company_id', 'in', (False, self.company_id.id)),
|
||||
], order='priority, id')
|
||||
for rule in rules:
|
||||
if rule._match(self):
|
||||
return rule.template_id
|
||||
return self.env['rsbu.operation.template'].sudo()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Реализация точек миксина (Этап 3)
|
||||
# ------------------------------------------------------------------
|
||||
def _rsbu_is_applicable(self):
|
||||
self.ensure_one()
|
||||
return bool(self.rsbu_template_id or self._rsbu_detect_template())
|
||||
|
||||
def _rsbu_build_lines(self):
|
||||
self.ensure_one()
|
||||
template = (self.rsbu_template_id or self._rsbu_detect_template()).sudo()
|
||||
if not template:
|
||||
return []
|
||||
fn = get_resolver(template.resolver_key)
|
||||
if not fn:
|
||||
return []
|
||||
return fn(self, template)
|
||||
74
rsbu_mapping/models/account_payment.py
Normal file
74
rsbu_mapping/models/account_payment.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from odoo import api, fields, models
|
||||
from ..resolvers.registry import get_resolver
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
rsbu_vat_amount = fields.Monetary(
|
||||
string='В том числе НДС', currency_field='currency_id',
|
||||
help='Сумма НДС в составе платежа (для авансового НДС). '
|
||||
'Заполняется вручную или при загрузке банковской выписки.')
|
||||
rsbu_template_id = fields.Many2one(
|
||||
'rsbu.operation.template', string='Шаблон РСБУ',
|
||||
compute='_compute_rsbu_template_id', store=True, readonly=False,
|
||||
copy=False)
|
||||
|
||||
@api.depends('payment_type', 'state')
|
||||
def _compute_rsbu_template_id(self):
|
||||
for payment in self:
|
||||
if payment.rsbu_template_id:
|
||||
continue
|
||||
payment.rsbu_template_id = payment._rsbu_detect_template()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Классификация
|
||||
# ------------------------------------------------------------------
|
||||
def _rsbu_doc_kind(self):
|
||||
self.ensure_one()
|
||||
return 'payment_inbound' if self.payment_type == 'inbound' \
|
||||
else 'payment_outbound'
|
||||
|
||||
def _rsbu_has_advance(self):
|
||||
self.ensure_one()
|
||||
# Признак используется матрицей лишь при has_advance != 'any'.
|
||||
# Для клиентских платежей тип (аванс/постоплата/переплата) определяет
|
||||
# резолвер customer_payment по сальдо дебиторки на дату платежа, а
|
||||
# матрица ведёт на один шаблон (has_advance='any'), поэтому этот
|
||||
# признак на выбор шаблона клиентского платежа не влияет.
|
||||
return self.payment_type == 'inbound' \
|
||||
and self.partner_type == 'customer'
|
||||
|
||||
def _rsbu_partner_kind(self):
|
||||
self.ensure_one()
|
||||
return self.partner_type or 'any'
|
||||
|
||||
def _rsbu_detect_template(self):
|
||||
self.ensure_one()
|
||||
# sudo: детект — внутренняя механика; пользователь, проводящий
|
||||
# документ, может не иметь прав на РСБУ-модели.
|
||||
Matrix = self.env['rsbu.operation.matrix'].sudo()
|
||||
rules = Matrix.search([
|
||||
('company_id', 'in', (False, self.company_id.id)),
|
||||
], order='priority, id')
|
||||
for rule in rules:
|
||||
if rule._match(self):
|
||||
return rule.template_id
|
||||
return self.env['rsbu.operation.template'].sudo()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Реализация точек миксина (Этап 3)
|
||||
# ------------------------------------------------------------------
|
||||
def _rsbu_is_applicable(self):
|
||||
self.ensure_one()
|
||||
return bool(self.rsbu_template_id or self._rsbu_detect_template())
|
||||
|
||||
def _rsbu_build_lines(self):
|
||||
self.ensure_one()
|
||||
template = (self.rsbu_template_id or self._rsbu_detect_template()).sudo()
|
||||
if not template:
|
||||
return []
|
||||
fn = get_resolver(template.resolver_key)
|
||||
if not fn:
|
||||
return []
|
||||
return fn(self, template)
|
||||
58
rsbu_mapping/models/rsbu_operation_matrix.py
Normal file
58
rsbu_mapping/models/rsbu_operation_matrix.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class RsbuOperationMatrix(models.Model):
|
||||
"""Матрица связности: подбор шаблона по признакам документа."""
|
||||
_name = 'rsbu.operation.matrix'
|
||||
_description = 'РСБУ — Матрица связности операций'
|
||||
_order = 'priority, id'
|
||||
_check_company_auto = True
|
||||
|
||||
name = fields.Char(string='Наименование правила', required=True)
|
||||
doc_kind = fields.Selection(
|
||||
selection=[
|
||||
# account.move
|
||||
('out_invoice', 'Счёт покупателю (реализация)'),
|
||||
('out_refund', 'Возврат покупателю (кредит-нота)'),
|
||||
('in_invoice', 'Счёт поставщика'),
|
||||
('in_refund', 'Возврат поставщику'),
|
||||
# account.payment
|
||||
('payment_inbound', 'Входящий платёж'),
|
||||
('payment_outbound', 'Исходящий платёж'),
|
||||
],
|
||||
string='Тип документа', required=True)
|
||||
has_advance = fields.Selection(
|
||||
selection=[('any', 'Не важно'), ('yes', 'Есть аванс'),
|
||||
('no', 'Без аванса')],
|
||||
string='Признак аванса', default='any', required=True)
|
||||
partner_kind = fields.Selection(
|
||||
selection=[('any', 'Не важно'), ('customer', 'Клиент'),
|
||||
('supplier', 'Поставщик')],
|
||||
string='Тип контрагента', default='any', required=True,
|
||||
help='Для платежей: различает клиента и поставщика при '
|
||||
'одинаковом направлении платежа.')
|
||||
template_id = fields.Many2one(
|
||||
'rsbu.operation.template', string='Шаблон РСБУ', required=True,
|
||||
check_company=True)
|
||||
priority = fields.Integer(string='Приоритет', default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания',
|
||||
default=lambda self: self.env.company)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
def _match(self, doc):
|
||||
"""Подходит ли правило документу."""
|
||||
self.ensure_one()
|
||||
doc_kind = doc._rsbu_doc_kind()
|
||||
if self.doc_kind != doc_kind:
|
||||
return False
|
||||
if self.has_advance != 'any':
|
||||
has_adv = doc._rsbu_has_advance()
|
||||
if self.has_advance == 'yes' and not has_adv:
|
||||
return False
|
||||
if self.has_advance == 'no' and has_adv:
|
||||
return False
|
||||
if self.partner_kind != 'any':
|
||||
if self.partner_kind != doc._rsbu_partner_kind():
|
||||
return False
|
||||
return True
|
||||
82
rsbu_mapping/models/rsbu_operation_template.py
Normal file
82
rsbu_mapping/models/rsbu_operation_template.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from ..resolvers.registry import resolver_selection
|
||||
|
||||
# Роли счетов, используемые резолверами.
|
||||
ROLE_SELECTION = [
|
||||
('bank', 'Денежный счёт (51/50)'),
|
||||
('advance_received', 'Авансы полученные (62.АВ)'),
|
||||
('receivable', 'Расчёты с покупателями (62.01)'),
|
||||
('revenue', 'Выручка (90.01)'),
|
||||
('revenue_vat', 'НДС в составе продаж (90.03)'),
|
||||
('vat_output', 'НДС начисленный (68.НДС)'),
|
||||
('vat_advance', 'НДС с авансов (76.АВ)'),
|
||||
('payable', 'Расчёты с поставщиками (60.01)'),
|
||||
('advance_paid', 'Авансы выданные (60.02)'),
|
||||
('expense', 'Затраты / расходы (44/26/20)'),
|
||||
('vat_input', 'Входящий НДС (19)'),
|
||||
]
|
||||
|
||||
|
||||
class RsbuOperationTemplate(models.Model):
|
||||
"""Шаблон типовой операции РСБУ (декларативная часть маппинга)."""
|
||||
_name = 'rsbu.operation.template'
|
||||
_description = 'РСБУ — Шаблон типовой операции'
|
||||
_check_company_auto = True
|
||||
|
||||
name = fields.Char(string='Наименование', required=True)
|
||||
code = fields.Char(string='Код', required=True)
|
||||
resolver_key = fields.Selection(
|
||||
selection=lambda self: resolver_selection(),
|
||||
string='Резолвер', required=True,
|
||||
help='Python-резолвер (в коде модуля), формирующий проводки.')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания',
|
||||
default=lambda self: self.env.company)
|
||||
role_ids = fields.One2many(
|
||||
'rsbu.operation.template.role', 'template_id', string='Роли счетов')
|
||||
active = fields.Boolean(default=True)
|
||||
note = fields.Text(string='Описание логики')
|
||||
|
||||
_code_uniq = models.Constraint(
|
||||
'unique(code, company_id)',
|
||||
'Код шаблона должен быть уникальным в пределах компании.')
|
||||
|
||||
def _account(self, role):
|
||||
"""Счёт РСБУ по роли шаблона (обязательная роль)."""
|
||||
self.ensure_one()
|
||||
line = self.role_ids.filtered(lambda r: r.role == role)[:1]
|
||||
if not line:
|
||||
raise UserError(_(
|
||||
'В шаблоне «%(tmpl)s» не задана роль счёта «%(role)s».',
|
||||
tmpl=self.display_name, role=role))
|
||||
return line.account_id
|
||||
|
||||
def _account_or_none(self, role):
|
||||
"""Счёт РСБУ по роли шаблона или None, если роль не задана."""
|
||||
self.ensure_one()
|
||||
line = self.role_ids.filtered(lambda r: r.role == role)[:1]
|
||||
return line.account_id if line else None
|
||||
|
||||
|
||||
class RsbuOperationTemplateRole(models.Model):
|
||||
"""Роль счёта в шаблоне операции."""
|
||||
_name = 'rsbu.operation.template.role'
|
||||
_description = 'РСБУ — Роль счёта в шаблоне'
|
||||
_check_company_auto = True
|
||||
|
||||
template_id = fields.Many2one(
|
||||
'rsbu.operation.template', string='Шаблон', required=True,
|
||||
ondelete='cascade')
|
||||
role = fields.Selection(
|
||||
selection=ROLE_SELECTION, string='Роль', required=True)
|
||||
account_id = fields.Many2one(
|
||||
'rsbu.account', string='Счёт РСБУ', required=True, check_company=True)
|
||||
company_id = fields.Many2one(
|
||||
related='template_id.company_id', store=True)
|
||||
subconto_source = fields.Selection(
|
||||
selection=[
|
||||
('partner_from_doc', 'Контрагент из документа'),
|
||||
('none', 'Без субконто по умолчанию'),
|
||||
],
|
||||
string='Источник субконто', default='partner_from_doc')
|
||||
2
rsbu_mapping/resolvers/__init__.py
Normal file
2
rsbu_mapping/resolvers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import registry
|
||||
from . import commercial
|
||||
422
rsbu_mapping/resolvers/commercial.py
Normal file
422
rsbu_mapping/resolvers/commercial.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Резолверы коммерческого цикла (по регламенту docs/research/08).
|
||||
|
||||
Каждый резолвер получает документ-родитель (account.move / account.payment)
|
||||
и шаблон rsbu.operation.template (роли счетов). Возвращает список значений
|
||||
строк rsbu.move.line, сбалансированный по Дт=Кт. Ставка НДС берётся из
|
||||
инвойса (налоговые строки) либо из поля rsbu_vat_amount платежа.
|
||||
"""
|
||||
from .registry import resolver
|
||||
|
||||
|
||||
def _line(account, debit=0.0, credit=0.0, partner=None, product=None,
|
||||
user=None, employee=None, name=None, origin=None,
|
||||
origin_self=False, contract=None):
|
||||
vals = {
|
||||
'account_id': account.id,
|
||||
'debit': debit,
|
||||
'credit': credit,
|
||||
}
|
||||
if partner:
|
||||
vals['partner_id'] = partner.id
|
||||
if product:
|
||||
vals['product_id'] = product.id
|
||||
if user:
|
||||
vals['user_id'] = user.id
|
||||
if employee:
|
||||
vals['employee_id'] = employee.id
|
||||
if name:
|
||||
vals['name'] = name
|
||||
# Субконто «Документ-основание» (аванс): либо явная проводка-основание,
|
||||
# либо маркер origin_self (ссылка на саму создаваемую проводку —
|
||||
# проставляется миксином после create).
|
||||
if origin is not None:
|
||||
vals['origin_move_id'] = origin.id
|
||||
if origin_self:
|
||||
vals['origin_self'] = True
|
||||
# Субконто «Договор».
|
||||
if contract is not None:
|
||||
vals['contract_id'] = contract.id
|
||||
return vals
|
||||
|
||||
|
||||
def _doc_contract(doc):
|
||||
"""Договор документа-источника (partner.contract.customer) или None."""
|
||||
return getattr(doc, 'mt_contract_id', False) or None
|
||||
|
||||
|
||||
@resolver('advance_received')
|
||||
def advance_received(doc, template):
|
||||
"""Получение аванса от покупателя (входящий платёж).
|
||||
|
||||
Дт bank — Кт advance_received (сумма платежа);
|
||||
Дт vat_advance — Кт vat_output (авансовый НДС из rsbu_vat_amount).
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
amount = abs(doc.amount)
|
||||
vat = abs(getattr(doc, 'rsbu_vat_amount', 0.0) or 0.0)
|
||||
contract = _doc_contract(doc)
|
||||
bank = template._account('bank')
|
||||
advance = template._account('advance_received')
|
||||
lines = [
|
||||
_line(bank, debit=amount, name='Поступление аванса'),
|
||||
# origin_self: аванс ссылается на свою проводку — для дроблёного
|
||||
# зачёта по основанию в shipment_upd (get_origin_balances).
|
||||
_line(advance, credit=amount, partner=partner, contract=contract,
|
||||
origin_self=True, name='Аванс полученный'),
|
||||
]
|
||||
if vat:
|
||||
vat_advance = template._account('vat_advance')
|
||||
vat_output = template._account('vat_output')
|
||||
lines += [
|
||||
_line(vat_advance, debit=vat, partner=partner,
|
||||
name='НДС с аванса'),
|
||||
_line(vat_output, credit=vat, name='НДС с аванса начислен'),
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('advance_refund')
|
||||
def advance_refund(doc, template):
|
||||
"""Возврат аванса покупателю.
|
||||
|
||||
Дт advance_received — Кт bank; вычет авансового НДС:
|
||||
Дт vat_output — Кт vat_advance.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
amount = abs(doc.amount)
|
||||
vat = abs(getattr(doc, 'rsbu_vat_amount', 0.0) or 0.0)
|
||||
bank = template._account('bank')
|
||||
advance = template._account('advance_received')
|
||||
lines = [
|
||||
_line(advance, debit=amount, partner=partner, name='Возврат аванса'),
|
||||
_line(bank, credit=amount, name='Возврат аванса с р/с'),
|
||||
]
|
||||
if vat:
|
||||
vat_advance = template._account('vat_advance')
|
||||
vat_output = template._account('vat_output')
|
||||
lines += [
|
||||
_line(vat_output, debit=vat, name='Вычет НДС с аванса'),
|
||||
_line(vat_advance, credit=vat, partner=partner,
|
||||
name='Сторно НДС с аванса'),
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('shipment_upd')
|
||||
def shipment_upd(doc, template):
|
||||
"""Отгрузка / выполнение работ / УПД (исходящий инвойс).
|
||||
|
||||
Базовая реализация (брутто):
|
||||
Дт 62.01 — Кт 90.01 на полную сумму (выручка с НДС);
|
||||
Дт 90.03 — Кт 68.НДС на сумму НДС (НДС в составе продаж).
|
||||
|
||||
Дополнительно, если по контрагенту есть незакрытый аванс (кредитовый
|
||||
остаток 62.АВ), формируются проводки зачёта аванса и вычета авансового
|
||||
НДС (по регламенту 08, ч.1 п.6):
|
||||
Дт 62.АВ — Кт 62.01 на сумму зачёта;
|
||||
Дт 68.НДС — Кт 76.АВ на авансовый НДС, приходящийся на зачёт.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
company_id = doc.company_id.id
|
||||
tax = abs(doc.amount_tax)
|
||||
total = abs(doc.amount_total)
|
||||
contract = _doc_contract(doc)
|
||||
receivable = template._account('receivable')
|
||||
revenue = template._account('revenue')
|
||||
|
||||
lines = [
|
||||
_line(receivable, debit=total, partner=partner, contract=contract,
|
||||
name='Задолженность покупателя'),
|
||||
_line(revenue, credit=total, name='Выручка'),
|
||||
]
|
||||
if tax:
|
||||
revenue_vat = template._account('revenue_vat')
|
||||
vat_output = template._account('vat_output')
|
||||
lines += [
|
||||
_line(revenue_vat, debit=tax, name='НДС с реализации'),
|
||||
_line(vat_output, credit=tax, name='НДС с реализации начислен'),
|
||||
]
|
||||
|
||||
# --- Зачёт аванса, ДРОБЛЁННЫЙ по каждому авансу-основанию (FIFO) ---
|
||||
# Как в 1С: Дт 62.02 — Кт 62.01 отдельной строкой на каждый аванс,
|
||||
# с субконто «Документ-основание» = проводка аванса, в разрезе договора.
|
||||
# Вычет авансового НДС здесь НЕ делается — он выполняется регламентом
|
||||
# «Формирование записей книги покупок» в конце периода (68.02 — 76.АВ).
|
||||
advance = template._account_or_none('advance_received')
|
||||
if advance and partner:
|
||||
subconto = {'partner': partner.id}
|
||||
if contract:
|
||||
subconto['contract'] = contract.id
|
||||
origins = advance.get_origin_balances(
|
||||
date_to=doc.date, company_id=company_id, subconto=subconto)
|
||||
remaining = total
|
||||
for data in origins:
|
||||
if remaining <= 0:
|
||||
break
|
||||
# 62.02 пассивный: кредитовое сальдо = незакрытый аванс.
|
||||
available = data['balance'] if data['side'] == 'credit' else 0.0
|
||||
if available <= 0:
|
||||
continue
|
||||
take = min(available, remaining)
|
||||
take = round(take, 2)
|
||||
if take <= 0:
|
||||
continue
|
||||
origin_move = data['origin']
|
||||
lines += [
|
||||
_line(advance, debit=take, partner=partner, contract=contract,
|
||||
origin=origin_move, name='Зачёт аванса покупателя'),
|
||||
_line(receivable, credit=take, partner=partner,
|
||||
contract=contract,
|
||||
name='Зачёт аванса в счёт задолженности'),
|
||||
]
|
||||
remaining -= take
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('payment_after_shipment')
|
||||
def payment_after_shipment(doc, template):
|
||||
"""Постоплата: поступление денег после отгрузки.
|
||||
|
||||
Дт bank — Кт receivable.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
amount = abs(doc.amount)
|
||||
bank = template._account('bank')
|
||||
receivable = template._account('receivable')
|
||||
return [
|
||||
_line(bank, debit=amount, name='Оплата от покупателя'),
|
||||
_line(receivable, credit=amount, partner=partner,
|
||||
name='Погашение задолженности'),
|
||||
]
|
||||
|
||||
|
||||
# Расчётная ставка НДС с аванса по режиму НДС компании (доля налога в сумме).
|
||||
_ADVANCE_VAT_FRACTION = {
|
||||
'vat_5': 5.0 / 105.0,
|
||||
'vat_7': 7.0 / 107.0,
|
||||
}
|
||||
|
||||
|
||||
@resolver('customer_payment')
|
||||
def customer_payment(doc, template):
|
||||
"""Поступление денег от покупателя: аванс, постоплата или смешанно.
|
||||
|
||||
Только движение денег (как в 1С; авансовый НДС начисляется отдельным
|
||||
документом «Счёт-фактура на аванс», а не здесь). Тип определяется
|
||||
сальдо дебиторки 62.01 по контрагенту на дату платежа:
|
||||
|
||||
* есть незакрытая дебиторка → Дт 51 — Кт 62.01 на min(долг, платёж)
|
||||
(постоплата, гасит долг по каждому основанию);
|
||||
* остаток сверх долга → аванс: Дт 51 — Кт 62.02 с субконто
|
||||
«Документ-основание» = сама проводка платежа (origin_self), чтобы
|
||||
затем вести зачёт по каждому авансу отдельно.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
company_id = doc.company_id.id
|
||||
amount = abs(doc.amount)
|
||||
bank = template._account('bank')
|
||||
contract = _doc_contract(doc)
|
||||
|
||||
lines = [_line(bank, debit=amount, name='Оплата от покупателя')]
|
||||
|
||||
# Долг покупателя на дату платежа — в разрезе (контрагент, договор),
|
||||
# как в 1С (зачёт и погашение ведутся по договору-основанию).
|
||||
receivable = template._account_or_none('receivable')
|
||||
debt = 0.0
|
||||
if receivable and partner:
|
||||
subconto = {'partner': partner.id}
|
||||
if contract:
|
||||
subconto['contract'] = contract.id
|
||||
bal = receivable.get_balance(
|
||||
date_to=doc.date, company_id=company_id, subconto=subconto)
|
||||
# 62.01 активный: дебетовое сальдо = долг покупателя по договору.
|
||||
debt = bal['balance'] if bal['side'] == 'debit' else 0.0
|
||||
|
||||
pay_debt = min(debt, amount)
|
||||
advance = round(amount - pay_debt, 2)
|
||||
|
||||
# Погашение задолженности (постоплата) — по тому же договору.
|
||||
if pay_debt > 0 and receivable:
|
||||
lines.append(_line(receivable, credit=pay_debt, partner=partner,
|
||||
contract=contract,
|
||||
name='Погашение задолженности покупателя'))
|
||||
|
||||
# Остаток — аванс (субконто: договор + основание = сама проводка).
|
||||
if advance > 0:
|
||||
advance_acc = template._account('advance_received')
|
||||
lines.append(_line(advance_acc, credit=advance, partner=partner,
|
||||
contract=contract, origin_self=True,
|
||||
name='Аванс полученный'))
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('sale_credit_note')
|
||||
def sale_credit_note(doc, template):
|
||||
"""Возврат от клиента / кредит-нота (out_refund).
|
||||
|
||||
Сторно реализации: Дт revenue — Кт receivable (сумма без НДС);
|
||||
сторно НДС: Дт vat_output — Кт revenue_vat.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
tax = abs(doc.amount_tax)
|
||||
total = abs(doc.amount_total)
|
||||
receivable = template._account('receivable')
|
||||
revenue = template._account('revenue')
|
||||
# Зеркально shipment_upd: Дт 90.01 — Кт 62.01 на полную сумму;
|
||||
# Дт 68.НДС — Кт 90.03 на сумму НДС (сторно начисления).
|
||||
lines = [
|
||||
_line(revenue, debit=total, name='Сторно выручки'),
|
||||
_line(receivable, credit=total, partner=partner,
|
||||
name='Уменьшение задолженности покупателя'),
|
||||
]
|
||||
if tax:
|
||||
revenue_vat = template._account('revenue_vat')
|
||||
vat_output = template._account('vat_output')
|
||||
lines += [
|
||||
_line(vat_output, debit=tax, name='Сторно НДС с реализации'),
|
||||
_line(revenue_vat, credit=tax, name='Сторно НДС в продажах'),
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('purchase_bill')
|
||||
def purchase_bill(doc, template):
|
||||
"""Счёт/поступление поставщика (in_invoice).
|
||||
|
||||
Дт expense(26) — Кт payable(60.01) на полную сумму (на УСН входящий
|
||||
НДС к вычету не принимается и включается в стоимость).
|
||||
|
||||
Плюс зачёт аванса выданного, ДРОБЛЁННЫЙ по каждому авансу-основанию
|
||||
(FIFO) в разрезе договора, как в 1С: Дт 60.01 — Кт 60.02 отдельной
|
||||
строкой на каждый аванс (субконто «Документ-основание» = проводка
|
||||
аванса).
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
company_id = doc.company_id.id
|
||||
total = abs(doc.amount_total)
|
||||
contract = _doc_contract(doc)
|
||||
expense = template._account('expense')
|
||||
payable = template._account('payable')
|
||||
|
||||
lines = [
|
||||
_line(expense, debit=total, name='Затраты по счёту поставщика'),
|
||||
_line(payable, credit=total, partner=partner, contract=contract,
|
||||
name='Задолженность перед поставщиком'),
|
||||
]
|
||||
|
||||
# --- Зачёт аванса выданного (60.02), дроблёный по основаниям ---
|
||||
advance_paid = template._account_or_none('advance_paid')
|
||||
if advance_paid and partner:
|
||||
subconto = {'partner': partner.id}
|
||||
if contract:
|
||||
subconto['contract'] = contract.id
|
||||
origins = advance_paid.get_origin_balances(
|
||||
date_to=doc.date, company_id=company_id, subconto=subconto)
|
||||
remaining = total
|
||||
for data in origins:
|
||||
if remaining <= 0:
|
||||
break
|
||||
# 60.02 активный: дебетовое сальдо = незачтённый аванс выданный.
|
||||
available = data['balance'] if data['side'] == 'debit' else 0.0
|
||||
if available <= 0:
|
||||
continue
|
||||
take = round(min(available, remaining), 2)
|
||||
if take <= 0:
|
||||
continue
|
||||
origin_move = data['origin']
|
||||
lines += [
|
||||
_line(payable, debit=take, partner=partner, contract=contract,
|
||||
name='Зачёт аванса выданного'),
|
||||
_line(advance_paid, credit=take, partner=partner,
|
||||
contract=contract, origin=origin_move,
|
||||
name='Зачёт аванса в счёт поставки'),
|
||||
]
|
||||
remaining -= take
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('purchase_credit_note')
|
||||
def purchase_credit_note(doc, template):
|
||||
"""Возврат поставщику / кредит-нота поставщика (in_refund).
|
||||
|
||||
Дт payable — Кт expense (сторно затрат и задолженности).
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
total = abs(doc.amount_total)
|
||||
expense = template._account('expense')
|
||||
payable = template._account('payable')
|
||||
return [
|
||||
_line(payable, debit=total, partner=partner,
|
||||
name='Уменьшение задолженности перед поставщиком'),
|
||||
_line(expense, credit=total, name='Сторно затрат'),
|
||||
]
|
||||
|
||||
|
||||
@resolver('supplier_payment')
|
||||
def supplier_payment(doc, template):
|
||||
"""Оплата поставщику (исходящий платёж): постоплата и/или аванс выданный.
|
||||
|
||||
Зеркально customer_payment. Тип определяется сальдо кредиторки 60.01 по
|
||||
(поставщик, договор) на дату платежа:
|
||||
|
||||
* есть незакрытый долг перед поставщиком → Дт 60.01 — Кт 51 на
|
||||
min(долг, платёж) (погашение задолженности);
|
||||
* остаток сверх долга → аванс выданный: Дт 60.02 — Кт 51 с субконто
|
||||
«Документ-основание» = сама проводка платежа (origin_self), договор.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
company_id = doc.company_id.id
|
||||
amount = abs(doc.amount)
|
||||
bank = template._account('bank')
|
||||
payable = template._account('payable')
|
||||
contract = _doc_contract(doc)
|
||||
|
||||
# Долг перед поставщиком на дату — в разрезе (поставщик, договор).
|
||||
debt = 0.0
|
||||
if partner:
|
||||
subconto = {'partner': partner.id}
|
||||
if contract:
|
||||
subconto['contract'] = contract.id
|
||||
bal = payable.get_balance(
|
||||
date_to=doc.date, company_id=company_id, subconto=subconto)
|
||||
# 60.01 пассивный: кредитовое сальдо = наш долг поставщику.
|
||||
debt = bal['balance'] if bal['side'] == 'credit' else 0.0
|
||||
|
||||
pay_debt = min(debt, amount)
|
||||
advance = round(amount - pay_debt, 2)
|
||||
|
||||
lines = []
|
||||
# Погашение задолженности (постоплата).
|
||||
if pay_debt > 0:
|
||||
lines += [
|
||||
_line(payable, debit=pay_debt, partner=partner, contract=contract,
|
||||
name='Оплата поставщику'),
|
||||
_line(bank, credit=pay_debt, name='Списание с р/с'),
|
||||
]
|
||||
# Остаток — аванс выданный (60.02, origin_self, договор).
|
||||
if advance > 0:
|
||||
advance_paid = template._account('advance_paid')
|
||||
lines += [
|
||||
_line(advance_paid, debit=advance, partner=partner,
|
||||
contract=contract, origin_self=True,
|
||||
name='Аванс выданный поставщику'),
|
||||
_line(bank, credit=advance, name='Списание с р/с (аванс)'),
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
@resolver('supplier_refund')
|
||||
def supplier_refund(doc, template):
|
||||
"""Возврат денег от поставщика (входящий платёж).
|
||||
|
||||
Дт bank — Кт payable.
|
||||
"""
|
||||
partner = doc.partner_id
|
||||
amount = abs(doc.amount)
|
||||
payable = template._account('payable')
|
||||
bank = template._account('bank')
|
||||
return [
|
||||
_line(bank, debit=amount, name='Возврат от поставщика'),
|
||||
_line(payable, credit=amount, partner=partner,
|
||||
name='Уменьшение расчётов с поставщиком'),
|
||||
]
|
||||
26
rsbu_mapping/resolvers/registry.py
Normal file
26
rsbu_mapping/resolvers/registry.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Реестр Python-резолверов маппинга РСБУ.
|
||||
|
||||
Резолвер — функция fn(doc, template) -> list[dict], возвращающая
|
||||
сбалансированный набор значений строк rsbu.move.line (Дт/Кт + субконто).
|
||||
Резолверы регистрируются декоратором @resolver(key) при импорте модуля
|
||||
и не редактируются пользователем.
|
||||
"""
|
||||
|
||||
RESOLVER_REGISTRY = {}
|
||||
|
||||
|
||||
def resolver(key):
|
||||
"""Декоратор регистрации резолвера по ключу."""
|
||||
def deco(fn):
|
||||
RESOLVER_REGISTRY[key] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def get_resolver(key):
|
||||
return RESOLVER_REGISTRY.get(key)
|
||||
|
||||
|
||||
def resolver_selection():
|
||||
"""Список ключей резолверов для Selection-поля шаблона."""
|
||||
return [(key, key) for key in sorted(RESOLVER_REGISTRY)]
|
||||
7
rsbu_mapping/security/ir.model.access.csv
Normal file
7
rsbu_mapping/security/ir.model.access.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_rsbu_op_template_user,rsbu.operation.template user,model_rsbu_operation_template,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
|
||||
access_rsbu_op_template_mgr,rsbu.operation.template manager,model_rsbu_operation_template,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
|
||||
access_rsbu_op_role_user,rsbu.operation.template.role user,model_rsbu_operation_template_role,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
|
||||
access_rsbu_op_role_mgr,rsbu.operation.template.role manager,model_rsbu_operation_template_role,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
|
||||
access_rsbu_op_matrix_user,rsbu.operation.matrix user,model_rsbu_operation_matrix,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
|
||||
access_rsbu_op_matrix_mgr,rsbu.operation.matrix manager,model_rsbu_operation_matrix,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
|
||||
|
1
rsbu_mapping/tests/__init__.py
Normal file
1
rsbu_mapping/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_mapping
|
||||
305
rsbu_mapping/tests/test_mapping.py
Normal file
305
rsbu_mapping/tests/test_mapping.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRsbuMapping(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Map Клиент'})
|
||||
cls.journal_bank = cls.env['account.journal'].search(
|
||||
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
|
||||
limit=1)
|
||||
cls.Template = cls.env['rsbu.operation.template']
|
||||
cls.Matrix = cls.env['rsbu.operation.matrix']
|
||||
# Роли счетов из типового плана.
|
||||
cls.acc = lambda self, code: self.env['rsbu.account'].search([
|
||||
('code', '=', code),
|
||||
('company_id', '=', self.env.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 _advance_template(self):
|
||||
return self.Template.create({
|
||||
'code': 'ADV_IN',
|
||||
'name': 'Получение аванса',
|
||||
'resolver_key': 'advance_received',
|
||||
'role_ids': [
|
||||
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
|
||||
(0, 0, {'role': 'advance_received',
|
||||
'account_id': self._acc('62.АВ').id}),
|
||||
(0, 0, {'role': 'vat_advance',
|
||||
'account_id': self._acc('76.АВ').id}),
|
||||
(0, 0, {'role': 'vat_output',
|
||||
'account_id': self._acc('68.НДС').id}),
|
||||
],
|
||||
})
|
||||
|
||||
def _matrix_advance(self, template):
|
||||
return self.Matrix.create({
|
||||
'name': 'Входящий платёж → аванс',
|
||||
'doc_kind': 'payment_inbound',
|
||||
'has_advance': 'any',
|
||||
'template_id': template.id,
|
||||
'priority': 10,
|
||||
})
|
||||
|
||||
def _payment(self, amount=1050.0, vat=50.0):
|
||||
return self.env['account.payment'].create({
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amount,
|
||||
'rsbu_vat_amount': vat,
|
||||
'journal_id': self.journal_bank.id,
|
||||
})
|
||||
|
||||
def test_detect_template_by_matrix(self):
|
||||
tmpl = self._advance_template()
|
||||
self._matrix_advance(tmpl)
|
||||
pay = self._payment()
|
||||
self.assertEqual(pay._rsbu_detect_template(), tmpl)
|
||||
self.assertTrue(pay._rsbu_is_applicable())
|
||||
|
||||
def test_no_matrix_not_applicable(self):
|
||||
# Без матрицы документ не в РСБУ-периметре.
|
||||
pay = self._payment()
|
||||
self.assertFalse(pay._rsbu_detect_template())
|
||||
self.assertFalse(pay._rsbu_is_applicable())
|
||||
|
||||
def test_advance_resolver_lines_balanced(self):
|
||||
tmpl = self._advance_template()
|
||||
self._matrix_advance(tmpl)
|
||||
pay = self._payment(amount=1050.0, vat=50.0)
|
||||
lines = pay._rsbu_build_lines()
|
||||
total_debit = sum(l.get('debit', 0.0) for l in lines)
|
||||
total_credit = sum(l.get('credit', 0.0) for l in lines)
|
||||
self.assertEqual(total_debit, total_credit)
|
||||
# Дт 51 = 1050, авансовый НДС 50
|
||||
self.assertEqual(total_debit, 1050.0 + 50.0)
|
||||
|
||||
def test_full_flow_generate_rsbu_move(self):
|
||||
tmpl = self._advance_template()
|
||||
self._matrix_advance(tmpl)
|
||||
pay = self._payment(amount=1050.0, vat=50.0)
|
||||
pay.action_post()
|
||||
move = pay.rsbu_move_id
|
||||
self.assertTrue(move)
|
||||
self.assertEqual(move.state, 'posted')
|
||||
# Проверим сальдо: 51 дебет 1050
|
||||
acc51 = self._acc('51')
|
||||
bal = acc51.get_balance(company_id=self.company.id)
|
||||
self.assertEqual(bal['debit'], 1050.0)
|
||||
# 68.НДС кредит 50 (начислен авансовый НДС)
|
||||
acc68 = self._acc('68.НДС')
|
||||
bal68 = acc68.get_balance(company_id=self.company.id)
|
||||
self.assertEqual(bal68['credit'], 50.0)
|
||||
|
||||
def _sale_template(self, with_advance_roles=False):
|
||||
roles = [
|
||||
(0, 0, {'role': 'receivable', 'account_id': self._acc('62.01').id}),
|
||||
(0, 0, {'role': 'revenue', 'account_id': self._acc('90.01').id}),
|
||||
(0, 0, {'role': 'revenue_vat', 'account_id': self._acc('90.03').id}),
|
||||
(0, 0, {'role': 'vat_output', 'account_id': self._acc('68.НДС').id}),
|
||||
]
|
||||
if with_advance_roles:
|
||||
roles += [
|
||||
(0, 0, {'role': 'advance_received',
|
||||
'account_id': self._acc('62.АВ').id}),
|
||||
(0, 0, {'role': 'vat_advance',
|
||||
'account_id': self._acc('76.АВ').id}),
|
||||
]
|
||||
return self.Template.create({
|
||||
'code': 'SALE_T',
|
||||
'name': 'Продажа',
|
||||
'resolver_key': 'shipment_upd',
|
||||
'role_ids': roles,
|
||||
})
|
||||
|
||||
def _sale_invoice_with_vat(self):
|
||||
"""Инвойс с НДС: untaxed 147.00, tax 22.05, total 169.05."""
|
||||
tax = self.env['account.tax'].create({
|
||||
'name': 'НДС 15% demo',
|
||||
'amount': 15.0,
|
||||
'amount_type': 'percent',
|
||||
'type_tax_use': 'sale',
|
||||
})
|
||||
product = self.env['product.product'].create({'name': 'Demo P'})
|
||||
return self.env['account.move'].create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Demo',
|
||||
'quantity': 1,
|
||||
'price_unit': 147.0,
|
||||
'tax_ids': [(6, 0, tax.ids)],
|
||||
})],
|
||||
})
|
||||
|
||||
def test_sale_with_vat_balanced(self):
|
||||
# Регрессия: реализация с НДС должна давать сбалансированную проводку.
|
||||
tmpl = self._sale_template()
|
||||
inv = self._sale_invoice_with_vat()
|
||||
inv.rsbu_template_id = tmpl.id
|
||||
lines = inv._rsbu_build_lines()
|
||||
total_debit = sum(l.get('debit', 0.0) for l in lines)
|
||||
total_credit = sum(l.get('credit', 0.0) for l in lines)
|
||||
self.assertAlmostEqual(total_debit, total_credit, places=2)
|
||||
# Дт 62.01 = total 169.05, Дт 90.03 = НДС -> дебет = total + tax.
|
||||
self.assertAlmostEqual(total_debit, 169.05 + 22.05, places=2)
|
||||
|
||||
def test_advance_offset_on_shipment(self):
|
||||
"""Р1: при отгрузке после аванса зачёт 62.АВ и вычет авансового НДС."""
|
||||
# 1) Аванс клиента 105 (НДС 5) через резолвер advance_received.
|
||||
adv_tmpl = self._advance_template()
|
||||
self._matrix_advance(adv_tmpl)
|
||||
pay = self._payment(amount=105.0, vat=5.0)
|
||||
pay.action_post()
|
||||
self.assertTrue(pay.rsbu_move_id)
|
||||
|
||||
# Остатки после аванса: 62.АВ кредит 105, 76.АВ дебет 5.
|
||||
acc_av = self._acc('62.АВ')
|
||||
acc_vatav = self._acc('76.АВ')
|
||||
bal_av = acc_av.get_balance(
|
||||
company_id=self.company.id,
|
||||
subconto={'partner': self.partner.id})
|
||||
self.assertEqual(bal_av['balance'], 105.0)
|
||||
self.assertEqual(bal_av['side'], 'credit')
|
||||
|
||||
# 2) Отгрузка тому же клиенту, шаблон с ролями аванса.
|
||||
sale_tmpl = self._sale_template(with_advance_roles=True)
|
||||
inv = self._sale_invoice_with_vat() # total 169.05
|
||||
inv.rsbu_template_id = sale_tmpl.id
|
||||
lines = inv._rsbu_build_lines()
|
||||
# Проводка должна содержать зачёт аванса и вычет авансового НДС.
|
||||
d = sum(l.get('debit', 0.0) for l in lines)
|
||||
c = sum(l.get('credit', 0.0) for l in lines)
|
||||
self.assertAlmostEqual(d, c, places=2)
|
||||
inv.action_post()
|
||||
|
||||
# 3) После отгрузки зачёт аванса: 62.АВ по партнёру закрыт (=0).
|
||||
# Вычет авансового НДС (76.АВ) при отгрузке НЕ делается — он вынесен
|
||||
# в регламент конца периода (rsbu.period.close), поэтому 76.АВ здесь
|
||||
# остаётся ненулевым (закрывается регламентом «книга покупок»).
|
||||
bal_av2 = acc_av.get_balance(
|
||||
company_id=self.company.id,
|
||||
subconto={'partner': self.partner.id})
|
||||
self.assertAlmostEqual(bal_av2['balance'], 0.0, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# customer_payment: аванс / постоплата / переплата
|
||||
# ------------------------------------------------------------------
|
||||
def _customer_payment_template(self):
|
||||
tmpl = self.Template.create({
|
||||
'code': 'CUST_PAY',
|
||||
'name': 'Оплата от клиента',
|
||||
'resolver_key': 'customer_payment',
|
||||
'company_id': self.company.id,
|
||||
'role_ids': [
|
||||
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
|
||||
(0, 0, {'role': 'receivable',
|
||||
'account_id': self._acc('62.01').id}),
|
||||
(0, 0, {'role': 'advance_received',
|
||||
'account_id': self._acc('62.АВ').id}),
|
||||
(0, 0, {'role': 'vat_advance',
|
||||
'account_id': self._acc('76.АВ').id}),
|
||||
(0, 0, {'role': 'vat_output',
|
||||
'account_id': self._acc('68.НДС').id}),
|
||||
],
|
||||
})
|
||||
self.Matrix.create({
|
||||
'name': 'Оплата клиента',
|
||||
'doc_kind': 'payment_inbound',
|
||||
'partner_kind': 'customer',
|
||||
'has_advance': 'any',
|
||||
'template_id': tmpl.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
return tmpl
|
||||
|
||||
def _debt(self, partner, amount, date):
|
||||
"""Создать дебиторку покупателя (реализация) для теста постоплаты."""
|
||||
move = self.env['rsbu.move'].create({
|
||||
'date': date, 'company_id': self.company.id,
|
||||
'line_ids': [
|
||||
(0, 0, {'account_id': self._acc('62.01').id, 'debit': amount,
|
||||
'partner_id': partner.id}),
|
||||
(0, 0, {'account_id': self._acc('90.01').id, 'credit': amount}),
|
||||
],
|
||||
})
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
def _inbound(self, partner, amount, date):
|
||||
pay = self.env['account.payment'].create({
|
||||
'payment_type': 'inbound', 'partner_type': 'customer',
|
||||
'partner_id': partner.id, 'amount': amount,
|
||||
'journal_id': self.journal_bank.id, 'company_id': self.company.id,
|
||||
'date': date,
|
||||
})
|
||||
pay.action_post()
|
||||
return pay
|
||||
|
||||
def test_customer_payment_advance(self):
|
||||
"""Платёж без долга → аванс (Кт 62.АВ), только деньги.
|
||||
|
||||
Авансовый НДС в платеже НЕ начисляется: он живёт в отдельном
|
||||
авансовом счёте-фактуре (rsbu.invoice, invoice_type='advance'),
|
||||
рождаемом из платежа и не связанном с документом реализации
|
||||
(realization_id пуст). Модель-источник авансового НДС — rsbu.invoice,
|
||||
без изменений. Платёж двигает только деньги.
|
||||
"""
|
||||
self.company.rsbu_vat_mode = 'vat_5'
|
||||
self._customer_payment_template()
|
||||
p = self.env['res.partner'].create({'name': 'Аванс-клиент'})
|
||||
pay = self._inbound(p, 105000.0, '2026-03-01')
|
||||
acc_av = self._acc('62.АВ')
|
||||
bal = acc_av.get_balance(company_id=self.company.id,
|
||||
subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal['balance'], 105000.0, places=2)
|
||||
# Авансовый НДС в платеже не начисляется (68.НДС не затронут платежом).
|
||||
# дебиторка 62.01 не затронута
|
||||
bal01 = self._acc('62.01').get_balance(
|
||||
company_id=self.company.id, subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal01['balance'], 0.0, places=2)
|
||||
|
||||
def test_customer_payment_postpaid(self):
|
||||
"""Платёж при наличии долга → погашение 62.01, без аванса и НДС."""
|
||||
self.company.rsbu_vat_mode = 'vat_5'
|
||||
self._customer_payment_template()
|
||||
p = self.env['res.partner'].create({'name': 'Постоплата-клиент'})
|
||||
self._debt(p, 50000.0, '2026-03-05')
|
||||
pay = self._inbound(p, 50000.0, '2026-03-10')
|
||||
# 62.01 закрыт (0), 62.АВ не создавался, авансовый НДС не начислен
|
||||
bal01 = self._acc('62.01').get_balance(
|
||||
company_id=self.company.id, subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal01['balance'], 0.0, places=2)
|
||||
bal_av = self._acc('62.АВ').get_balance(
|
||||
company_id=self.company.id, subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal_av['balance'], 0.0, places=2)
|
||||
|
||||
def test_customer_payment_overpaid(self):
|
||||
"""Переплата → долг гасится (62.01), остаток в аванс (62.АВ).
|
||||
|
||||
Авансовый НДС в платеже не начисляется (вынесен в СФ на аванс).
|
||||
"""
|
||||
self.company.rsbu_vat_mode = 'vat_5'
|
||||
self._customer_payment_template()
|
||||
p = self.env['res.partner'].create({'name': 'Переплата-клиент'})
|
||||
self._debt(p, 30000.0, '2026-03-06')
|
||||
pay = self._inbound(p, 100000.0, '2026-03-11')
|
||||
# долг 62.01 закрыт
|
||||
bal01 = self._acc('62.01').get_balance(
|
||||
company_id=self.company.id, subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal01['balance'], 0.0, places=2)
|
||||
# аванс на остаток 70000
|
||||
bal_av = self._acc('62.АВ').get_balance(
|
||||
company_id=self.company.id, subconto={'partner': p.id})
|
||||
self.assertAlmostEqual(bal_av['balance'], 70000.0, places=2)
|
||||
# Авансовый НДС в платеже не начисляется (76.АВ платежом не затронут).
|
||||
18
rsbu_mapping/views/account_move_views.xml
Normal file
18
rsbu_mapping/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_tab" model="ir.ui.view">
|
||||
<field name="name">account.move.form.rsbu.tab</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<notebook position="inside">
|
||||
<page string="РСБУ" name="rsbu">
|
||||
<group>
|
||||
<field name="rsbu_template_id"/>
|
||||
<field name="rsbu_move_id" readonly="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
14
rsbu_mapping/views/account_payment_views.xml
Normal file
14
rsbu_mapping/views/account_payment_views.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_account_payment_form_rsbu_tab" model="ir.ui.view">
|
||||
<field name="name">account.payment.form.rsbu.tab</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="inherit_id" ref="account.view_account_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="amount" position="after">
|
||||
<field name="rsbu_vat_amount"/>
|
||||
<field name="rsbu_template_id"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
12
rsbu_mapping/views/rsbu_menus.xml
Normal file
12
rsbu_mapping/views/rsbu_menus.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem id="menu_rsbu_operation_template" name="Шаблоны операций"
|
||||
parent="rsbu_chart_of_accounts.menu_rsbu_config"
|
||||
action="action_rsbu_operation_template" sequence="20"
|
||||
groups="rsbu_journal.group_rsbu_journal_manager"/>
|
||||
|
||||
<menuitem id="menu_rsbu_operation_matrix" name="Матрица связности"
|
||||
parent="rsbu_chart_of_accounts.menu_rsbu_config"
|
||||
action="action_rsbu_operation_matrix" sequence="30"
|
||||
groups="rsbu_journal.group_rsbu_journal_manager"/>
|
||||
</odoo>
|
||||
82
rsbu_mapping/views/rsbu_operation_views.xml
Normal file
82
rsbu_mapping/views/rsbu_operation_views.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Шаблон операции -->
|
||||
<record id="view_rsbu_operation_template_form" model="ir.ui.view">
|
||||
<field name="name">rsbu.operation.template.form</field>
|
||||
<field name="model">rsbu.operation.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Шаблон типовой операции">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="resolver_key"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Роли счетов">
|
||||
<field name="role_ids">
|
||||
<list editable="bottom">
|
||||
<field name="role"/>
|
||||
<field name="account_id"/>
|
||||
<field name="subconto_source"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Описание">
|
||||
<field name="note"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rsbu_operation_template_list" model="ir.ui.view">
|
||||
<field name="name">rsbu.operation.template.list</field>
|
||||
<field name="model">rsbu.operation.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Шаблоны типовых операций">
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="resolver_key"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rsbu_operation_template" model="ir.actions.act_window">
|
||||
<field name="name">Шаблоны типовых операций</field>
|
||||
<field name="res_model">rsbu.operation.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Матрица связности -->
|
||||
<record id="view_rsbu_operation_matrix_list" model="ir.ui.view">
|
||||
<field name="name">rsbu.operation.matrix.list</field>
|
||||
<field name="model">rsbu.operation.matrix</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Матрица связности" editable="bottom">
|
||||
<field name="priority" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="doc_kind"/>
|
||||
<field name="has_advance"/>
|
||||
<field name="partner_kind"/>
|
||||
<field name="template_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rsbu_operation_matrix" model="ir.actions.act_window">
|
||||
<field name="name">Матрица связности</field>
|
||||
<field name="res_model">rsbu.operation.matrix</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user