Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
149
ruodoo_commission/README.rst
Normal file
149
ruodoo_commission/README.rst
Normal file
@@ -0,0 +1,149 @@
|
||||
========================
|
||||
РуОду — Комиссии агентам
|
||||
========================
|
||||
|
||||
.. |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|
|
||||
|
||||
Учёт агентских вознаграждений (комиссий) по сделкам. Комиссии
|
||||
привязываются к строкам заказа продаж по аналогии с налогами: к одной
|
||||
строке можно привязать несколько комиссий. По подтверждённому заказу
|
||||
комиссии начисляются одним действием в виде счетов поставщика
|
||||
(Vendor Bill), сгруппированных по партнёрам-получателям.
|
||||
|
||||
**Содержание**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Возможности
|
||||
===========
|
||||
|
||||
* Справочник видов комиссий (меню *Учёт → Комиссии агентам*):
|
||||
|
||||
* расчёт *Процентом* от суммы строки без налогов;
|
||||
* расчёт *Формулой* — python-код вида ``result = ...`` по аналогии
|
||||
с правилами модуля зарплаты;
|
||||
* журнал и счёт расходов для будущих проводок, партнёр-получатель,
|
||||
опциональный аналитический счёт.
|
||||
|
||||
* Привязка нескольких комиссий к строке заказа (``sale.order.line``),
|
||||
колонка «Комиссии» и расчёт суммы комиссий по строке.
|
||||
* Строка «ИТОГО КОМИССИЙ» в блоке итогов заказа (не входит в итог
|
||||
заказа — это забалансовый агентский расход).
|
||||
* Кнопка «Начислить комиссию» на подтверждённом заказе: формирование
|
||||
и проведение счетов поставщика по каждому партнёру-получателю со
|
||||
счетами из настроек комиссии.
|
||||
* Смарт-кнопка «Комиссии» со связанными счетами поставщика.
|
||||
* Каскадная отмена счетов комиссий при отмене заказа: отвязка платежей
|
||||
(штатными средствами), перевод в черновик и удаление.
|
||||
* Разграничение доступа группами «Видит комиссии» и
|
||||
«Начисляет комиссии».
|
||||
|
||||
Зависимости
|
||||
===========
|
||||
|
||||
* ``sale_management`` — заказы на продажу и строки заказа
|
||||
* ``account`` — счета поставщика (``account.move``), журналы и счета
|
||||
|
||||
Модели
|
||||
======
|
||||
|
||||
* ``commission.commission`` — вид комиссии агенту (способ расчёта,
|
||||
журнал, счёт расходов, партнёр-получатель, аналитика).
|
||||
* ``sale.order.line`` (расширение) — поле ``commission_ids`` и
|
||||
вычисляемое ``commission_amount``.
|
||||
* ``sale.order`` (расширение) — ``amount_commission`` («ИТОГО КОМИССИЙ»),
|
||||
начисление и просмотр счетов комиссий, каскадная отмена.
|
||||
* ``account.move`` (расширение) — служебная ссылка
|
||||
``commission_sale_order_id`` на исходный заказ.
|
||||
|
||||
Группы доступа
|
||||
==============
|
||||
|
||||
* **Видит комиссии** (``group_commission_viewer``) — видит колонку
|
||||
«Комиссии», итог «ИТОГО КОМИССИЙ» и смарт-кнопку. Продавцы вне этой
|
||||
группы могут добавлять комиссии в строки, но не видят вывод.
|
||||
* **Начисляет комиссии** (``group_commission_manager``, включает
|
||||
«Видит комиссии») — доступна кнопка «Начислить комиссию».
|
||||
|
||||
Настройка
|
||||
=========
|
||||
|
||||
#. Откройте *Учёт → Комиссии агентам* и заведите нужные виды комиссий.
|
||||
#. Для способа «Процент» укажите ставку; для «Формулы» — python-код,
|
||||
присваивающий результат переменной ``result``.
|
||||
#. На каждой комиссии задайте журнал (покупки), счёт расходов
|
||||
(тип «Расходы»), партнёра-получателя и, при необходимости,
|
||||
аналитический счёт.
|
||||
#. Выдайте сотрудникам группы «Видит комиссии» и «Начисляет комиссии».
|
||||
|
||||
Формула комиссии
|
||||
================
|
||||
|
||||
В режиме «Формула» доступны переменные:
|
||||
|
||||
* ``line`` — строка заказа (``sale.order.line``)
|
||||
* ``order`` — заказ (``sale.order``)
|
||||
* ``product`` — товар строки (``product.product``)
|
||||
* ``qty`` — количество
|
||||
* ``price_unit`` — цена за единицу
|
||||
* ``price_subtotal`` — сумма строки без налогов
|
||||
* ``partner`` — контрагент заказа (``res.partner``)
|
||||
* ``commission`` — текущий вид комиссии
|
||||
|
||||
Результат присваивается переменной ``result``, например::
|
||||
|
||||
result = price_subtotal * 0.05
|
||||
|
||||
Использование
|
||||
=============
|
||||
|
||||
#. В строках заказа выберите нужные комиссии в колонке «Комиссии».
|
||||
В блоке итогов появится «ИТОГО КОМИССИЙ».
|
||||
#. Подтвердите заказ и нажмите «Начислить комиссию». Модуль обойдёт
|
||||
строки и привязанные комиссии, сгруппирует их по партнёру-получателю
|
||||
и сформирует один или несколько счетов поставщика, затем проведёт их.
|
||||
#. Нажмите смарт-кнопку «Комиссии», чтобы увидеть связанные счета.
|
||||
#. При отмене заказа счета комиссий автоматически отменяются, переводятся
|
||||
в черновик и удаляются; привязанные платежи предварительно
|
||||
отвязываются штатными средствами (при невозможности — выдаётся
|
||||
понятное сообщение).
|
||||
|
||||
Границы
|
||||
=======
|
||||
|
||||
* Нативная бухгалтерия Odoo не изменяется сверх создания и штатной
|
||||
обработки счетов поставщика и служебного поля-ссылки.
|
||||
* Проводки в РСБУ-журнал по счетам комиссий формирует штатный коннектор
|
||||
РСБУ-слоя по событию проведения (``action_post``) — вне этого модуля.
|
||||
|
||||
Тестирование
|
||||
============
|
||||
|
||||
Модуль покрыт тестами ``TransactionCase`` (расчёт процентом и формулой,
|
||||
несколько комиссий на строке, исключение комиссии из итога заказа,
|
||||
начисление и группировка счетов по получателям, дедупликация, каскадная
|
||||
отмена, смарт-кнопка). Запуск::
|
||||
|
||||
docker compose run --rm odoo odoo -d <db> -i ruodoo_commission \
|
||||
--test-enable --test-tags=/ruodoo_commission --stop-after-init
|
||||
|
||||
Авторы
|
||||
======
|
||||
|
||||
* MK.Lab, RuOdoo
|
||||
|
||||
Модуль сопровождается MK.Lab, RuOdoo.
|
||||
|
||||
.. image:: https://ruodoo.ru/logo.png
|
||||
:alt: MK.Lab, RuOdoo
|
||||
:target: https://ruodoo.ru
|
||||
|
||||
Модуль является частью решения РуОду.
|
||||
1
ruodoo_commission/__init__.py
Normal file
1
ruodoo_commission/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
42
ruodoo_commission/__manifest__.py
Normal file
42
ruodoo_commission/__manifest__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'РуОду — Комиссии агентам',
|
||||
'version': '19.0.2026.09.27',
|
||||
'summary': 'Учёт агентских вознаграждений (комиссий) по сделкам',
|
||||
'description': """
|
||||
РуОду — Комиссии агентам
|
||||
========================
|
||||
|
||||
Учёт агентских вознаграждений по сделкам, работающий по аналогии с налогами:
|
||||
|
||||
* Справочник видов комиссий (меню «Комиссии агентам» в модуле «Учёт»):
|
||||
расчёт как простой процент или как формула (python-код ``result = ...``),
|
||||
журнал и счёт расходов для проводок, партнёр-получатель, опц. аналитика.
|
||||
* Привязка нескольких комиссий к строке заказа (``sale.order.line``),
|
||||
колонка «Комиссии», расчёт суммы комиссий по строке и по заказу.
|
||||
* Строка «ИТОГО КОМИССИЙ» в блоке итогов заказа.
|
||||
* Кнопка «Начислить комиссию» на подтверждённом заказе: формирование и
|
||||
проведение счетов поставщика (Vendor Bill), сгруппированных по
|
||||
партнёру-получателю.
|
||||
* Смарт-кнопка «Комиссии» со связанными счетами поставщика.
|
||||
* Каскадная отмена счетов комиссий при отмене заказа.
|
||||
* Разграничение доступа группами «Видит комиссии» / «Начисляет комиссии».
|
||||
""",
|
||||
'author': 'MK.Lab, RuOdoo',
|
||||
'website': 'https://ruodoo.ru',
|
||||
'category': 'Accounting',
|
||||
'depends': [
|
||||
'sale_management',
|
||||
'account',
|
||||
],
|
||||
'data': [
|
||||
'security/commission_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/commission_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'maintainers': ['mklab', 'ruodoo'],
|
||||
}
|
||||
4
ruodoo_commission/models/__init__.py
Normal file
4
ruodoo_commission/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import commission
|
||||
from . import sale_order_line
|
||||
from . import sale_order
|
||||
from . import account_move
|
||||
11
ruodoo_commission/models/account_move.py
Normal file
11
ruodoo_commission/models/account_move.py
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
commission_sale_order_id = fields.Many2one(
|
||||
'sale.order', string='Заказ (комиссия)', index=True, copy=False,
|
||||
help='Заказ продаж, по которому начислен этот счёт комиссии агенту.',
|
||||
)
|
||||
141
ruodoo_commission/models/commission.py
Normal file
141
ruodoo_commission/models/commission.py
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
# Подсказка для поля формулы: доступные переменные и обязательный результат.
|
||||
CODE_HELP = (
|
||||
"Python-код расчёта комиссии. Результат присвоить переменной `result`.\n"
|
||||
"Доступные переменные:\n"
|
||||
" line — строка заказа (sale.order.line)\n"
|
||||
" order — заказ (sale.order)\n"
|
||||
" product — товар строки (product.product)\n"
|
||||
" qty — количество (product_uom_qty)\n"
|
||||
" price_unit — цена за единицу\n"
|
||||
" price_subtotal — сумма строки без налогов\n"
|
||||
" partner — контрагент заказа (res.partner)\n"
|
||||
" commission — текущий вид комиссии (commission.commission)\n"
|
||||
"Пример: result = price_subtotal * 0.05"
|
||||
)
|
||||
|
||||
DEFAULT_CODE = "result = price_subtotal * 0.0"
|
||||
|
||||
|
||||
class CommissionCommission(models.Model):
|
||||
_name = 'commission.commission'
|
||||
_description = 'Вид комиссии агенту'
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(string='Наименование', required=True, translate=True)
|
||||
active = fields.Boolean(string='Активно', default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания', required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
calc_type = fields.Selection(
|
||||
selection=[
|
||||
('percentage', 'Процент'),
|
||||
('code', 'Формула'),
|
||||
],
|
||||
string='Способ расчёта', required=True, default='percentage',
|
||||
)
|
||||
percentage = fields.Float(
|
||||
string='Процент, %', digits=(16, 4),
|
||||
help='Процент от суммы строки без налогов (для способа «Процент»).',
|
||||
)
|
||||
amount_python_compute = fields.Text(
|
||||
string='Формула (Python)', default=DEFAULT_CODE, help=CODE_HELP,
|
||||
)
|
||||
|
||||
# --- Настройки будущих проводок вендор-билла ---
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal', string='Журнал', required=True,
|
||||
domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
|
||||
help='Журнал для счетов поставщика (Vendor Bill) по комиссии.',
|
||||
)
|
||||
account_id = fields.Many2one(
|
||||
'account.account', string='Счёт расходов', required=True,
|
||||
domain="[('account_type', 'in', ('expense', 'expense_depreciation', 'expense_direct_cost'))]",
|
||||
help='Счёт расходов (тип «Расходы»), на котором копятся комиссии.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Получатель', required=True,
|
||||
help='Партнёр-агент, на кого выставляется вендор-билл по комиссии.',
|
||||
)
|
||||
analytic_account_id = fields.Many2one(
|
||||
'account.analytic.account', string='Аналитический счёт',
|
||||
help='Опциональный аналитический счёт для строки вендор-билла.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Расчёт суммы комиссии
|
||||
# ------------------------------------------------------------------
|
||||
def _get_eval_context(self, line):
|
||||
"""Ограниченный контекст выполнения формулы комиссии."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'line': line,
|
||||
'order': line.order_id,
|
||||
'product': line.product_id,
|
||||
'qty': line.product_uom_qty,
|
||||
'price_unit': line.price_unit,
|
||||
'price_subtotal': line.price_subtotal,
|
||||
'partner': line.order_id.partner_id,
|
||||
'commission': self,
|
||||
'result': 0.0,
|
||||
}
|
||||
|
||||
def _compute_amount(self, line):
|
||||
"""Сумма комиссии для конкретной строки заказа.
|
||||
|
||||
:param line: sale.order.line
|
||||
:return: float — сумма комиссии в валюте заказа
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.calc_type == 'percentage':
|
||||
return line.price_subtotal * (self.percentage or 0.0) / 100.0
|
||||
|
||||
# calc_type == 'code' — формула result = ...
|
||||
localdict = self._get_eval_context(line)
|
||||
try:
|
||||
safe_eval(
|
||||
self.amount_python_compute or '',
|
||||
localdict, mode='exec',
|
||||
)
|
||||
except Exception as ex:
|
||||
raise UserError(_(
|
||||
"Ошибка в формуле комиссии «%(name)s»:\n%(err)s",
|
||||
name=self.name, err=ex,
|
||||
)) from ex
|
||||
try:
|
||||
return float(localdict.get('result', 0.0))
|
||||
except (TypeError, ValueError):
|
||||
raise UserError(_(
|
||||
"Формула комиссии «%(name)s» вернула нечисловой результат.",
|
||||
name=self.name,
|
||||
)) from None
|
||||
|
||||
def _prepare_bill_line_vals(self, line, amount):
|
||||
"""Значения строки вендор-билла для этой комиссии по строке заказа.
|
||||
|
||||
:param line: sale.order.line — исходная строка заказа
|
||||
:param amount: float — рассчитанная сумма комиссии
|
||||
:return: dict значений для account.move.line (invoice_line_ids)
|
||||
"""
|
||||
self.ensure_one()
|
||||
vals = {
|
||||
'name': _(
|
||||
"Комиссия «%(commission)s» по %(order)s / %(product)s",
|
||||
commission=self.name,
|
||||
order=line.order_id.name,
|
||||
product=line.product_id.display_name or line.name,
|
||||
),
|
||||
'account_id': self.account_id.id,
|
||||
'quantity': 1.0,
|
||||
'price_unit': amount,
|
||||
}
|
||||
if self.analytic_account_id:
|
||||
# analytic_distribution — JSON {id_счёта: процент}, сумма = 100%.
|
||||
vals['analytic_distribution'] = {str(self.analytic_account_id.id): 100.0}
|
||||
return vals
|
||||
185
ruodoo_commission/models/sale_order.py
Normal file
185
ruodoo_commission/models/sale_order.py
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
amount_commission = fields.Monetary(
|
||||
string='ИТОГО КОМИССИЙ', compute='_compute_amount_commission',
|
||||
store=True, currency_field='currency_id',
|
||||
help='Суммарные агентские комиссии по заказу. '
|
||||
'Не входит в итог заказа — это забалансовый агентский расход.',
|
||||
)
|
||||
commission_bill_ids = fields.One2many(
|
||||
'account.move', 'commission_sale_order_id',
|
||||
string='Счета комиссий',
|
||||
)
|
||||
commission_bill_count = fields.Integer(
|
||||
string='Кол-во счетов комиссий',
|
||||
compute='_compute_commission_bill_count',
|
||||
)
|
||||
|
||||
@api.depends('order_line.commission_amount')
|
||||
def _compute_amount_commission(self):
|
||||
for order in self:
|
||||
order.amount_commission = sum(
|
||||
order.order_line.mapped('commission_amount')
|
||||
)
|
||||
|
||||
@api.depends('commission_bill_ids')
|
||||
def _compute_commission_bill_count(self):
|
||||
for order in self:
|
||||
order.commission_bill_count = len(order.commission_bill_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Начисление комиссии → счета поставщика (Vendor Bill)
|
||||
# ------------------------------------------------------------------
|
||||
def action_accrue_commission(self):
|
||||
"""Сформировать и провести вендор-биллы по комиссиям заказа.
|
||||
|
||||
Обходим строки → привязанные комиссии, группируем строки будущих
|
||||
биллов по (партнёр-получатель, журнал), создаём один или несколько
|
||||
account.move (in_invoice) и проводим их.
|
||||
"""
|
||||
AccountMove = self.env['account.move']
|
||||
created = AccountMove
|
||||
for order in self:
|
||||
if order.state != 'sale':
|
||||
raise UserError(_(
|
||||
"Начислять комиссию можно только по подтверждённому заказу."
|
||||
))
|
||||
# Дедупликация: не начисляем повторно при наличии активных биллов.
|
||||
existing = order.commission_bill_ids.filtered(
|
||||
lambda m: m.state != 'cancel'
|
||||
)
|
||||
if existing:
|
||||
raise UserError(_(
|
||||
"По заказу %(name)s комиссии уже начислены. "
|
||||
"Отмените или удалите существующие счета комиссий.",
|
||||
name=order.name,
|
||||
))
|
||||
|
||||
buckets = order._prepare_commission_bill_buckets()
|
||||
for (partner, journal), line_vals_list in buckets.items():
|
||||
move = AccountMove.with_company(order.company_id).create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner.id,
|
||||
'journal_id': journal.id,
|
||||
'invoice_date': fields.Date.context_today(order),
|
||||
'invoice_origin': order.name,
|
||||
'commission_sale_order_id': order.id,
|
||||
'invoice_line_ids': [
|
||||
(0, 0, vals) for vals in line_vals_list
|
||||
],
|
||||
})
|
||||
move.action_post()
|
||||
created |= move
|
||||
|
||||
if not created:
|
||||
raise UserError(_(
|
||||
"По заказу нет комиссий с положительной суммой для начисления."
|
||||
))
|
||||
return self._action_open_commission_bills(created)
|
||||
|
||||
def _prepare_commission_bill_buckets(self):
|
||||
"""Справочник строк биллов, сгруппированный по (получатель, журнал).
|
||||
|
||||
:return: dict {(partner, journal): [invoice_line_vals, ...]}
|
||||
"""
|
||||
self.ensure_one()
|
||||
buckets = {}
|
||||
precision = self.currency_id.rounding or 0.01
|
||||
for line in self.order_line:
|
||||
for commission in line.commission_ids:
|
||||
amount = commission._compute_amount(line)
|
||||
if float_compare(amount, 0.0, precision_rounding=precision) <= 0:
|
||||
continue
|
||||
key = (commission.partner_id, commission.journal_id)
|
||||
buckets.setdefault(key, []).append(
|
||||
commission._prepare_bill_line_vals(line, amount)
|
||||
)
|
||||
return buckets
|
||||
|
||||
def _action_open_commission_bills(self, moves):
|
||||
"""Действие: открыть сформированные счета комиссий."""
|
||||
self.ensure_one()
|
||||
action = {
|
||||
'name': _('Счета комиссий'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'context': {'create': False},
|
||||
}
|
||||
if len(moves) == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': moves.id,
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', moves.ids)],
|
||||
})
|
||||
return action
|
||||
|
||||
def action_view_commission_bills(self):
|
||||
"""Смарт-кнопка «Комиссии»: связанные вендор-биллы заказа."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Комиссии'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('commission_sale_order_id', '=', self.id)],
|
||||
'context': {
|
||||
'create': False,
|
||||
'default_move_type': 'in_invoice',
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Отмена заказа → каскад на счета комиссий
|
||||
# ------------------------------------------------------------------
|
||||
def action_cancel(self):
|
||||
res = super().action_cancel()
|
||||
for order in self:
|
||||
bills = order.commission_bill_ids.filtered(
|
||||
lambda m: m.state != 'cancel'
|
||||
)
|
||||
if bills:
|
||||
order._cancel_commission_bills(bills)
|
||||
return res
|
||||
|
||||
def _cancel_commission_bills(self, bills):
|
||||
"""Отменить счета комиссий: отвязать платежи → черновик → удалить."""
|
||||
self.ensure_one()
|
||||
for bill in bills:
|
||||
self._detach_bill_payments(bill)
|
||||
if bill.state == 'posted':
|
||||
bill.button_draft()
|
||||
# Из черновика документ можно удалить штатно.
|
||||
bill.unlink()
|
||||
|
||||
def _detach_bill_payments(self, bill):
|
||||
"""Отвязать платежи от счёта комиссии штатными средствами.
|
||||
|
||||
Если ядро не позволяет отвязать платёж — сообщаем понятной ошибкой,
|
||||
деструктивных обходов ядра не выполняем.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Строки счёта, участвующие в сопоставлении с платежами.
|
||||
reconciled_lines = bill.line_ids.filtered(
|
||||
lambda ln: ln.matched_debit_ids or ln.matched_credit_ids
|
||||
)
|
||||
if not reconciled_lines:
|
||||
return
|
||||
try:
|
||||
reconciled_lines.remove_move_reconcile()
|
||||
except Exception as ex:
|
||||
raise UserError(_(
|
||||
"Не удалось отвязать платежи от счёта комиссии %(name)s. "
|
||||
"Сначала отмените сопоставление платежей вручную.\n%(err)s",
|
||||
name=bill.name or bill.display_name, err=ex,
|
||||
)) from ex
|
||||
30
ruodoo_commission/models/sale_order_line.py
Normal file
30
ruodoo_commission/models/sale_order_line.py
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
commission_ids = fields.Many2many(
|
||||
'commission.commission',
|
||||
'sale_order_line_commission_rel',
|
||||
'line_id', 'commission_id',
|
||||
string='Комиссии',
|
||||
domain="[('active', '=', True), ('company_id', '=', company_id)]",
|
||||
help='Комиссии агентам, начисляемые по этой строке заказа. '
|
||||
'Работает по аналогии с налогами: можно выбрать несколько.',
|
||||
)
|
||||
commission_amount = fields.Monetary(
|
||||
string='Сумма комиссий', compute='_compute_commission_amount',
|
||||
store=True, currency_field='currency_id',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'commission_ids', 'price_subtotal', 'product_uom_qty', 'price_unit',
|
||||
)
|
||||
def _compute_commission_amount(self):
|
||||
for line in self:
|
||||
line.commission_amount = sum(
|
||||
commission._compute_amount(line)
|
||||
for commission in line.commission_ids
|
||||
)
|
||||
28
ruodoo_commission/security/commission_security.xml
Normal file
28
ruodoo_commission/security/commission_security.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<!-- Группа: видит комиссии (колонка, итог, смарт-кнопка) -->
|
||||
<record id="group_commission_viewer" model="res.groups">
|
||||
<field name="name">Видит комиссии</field>
|
||||
<field name="implied_ids" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Группа: начисляет комиссии (кнопка «Начислить комиссию») -->
|
||||
<record id="group_commission_manager" model="res.groups">
|
||||
<field name="name">Начисляет комиссии</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_commission_viewer'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Мультикомпани: изоляция справочника видов комиссий -->
|
||||
<record id="commission_company_rule" model="ir.rule">
|
||||
<field name="name">commission.commission: мультикомпани</field>
|
||||
<field name="model_id" ref="model_commission_commission"/>
|
||||
<field name="global" eval="True"/>
|
||||
<field name="domain_force">
|
||||
['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
5
ruodoo_commission/security/ir.model.access.csv
Normal file
5
ruodoo_commission/security/ir.model.access.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_commission_commission_sale_user,commission.commission.sale.user,model_commission_commission,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_commission_commission_viewer,commission.commission.viewer,model_commission_commission,ruodoo_commission.group_commission_viewer,1,0,0,0
|
||||
access_commission_commission_manager,commission.commission.manager,model_commission_commission,ruodoo_commission.group_commission_manager,1,1,1,1
|
||||
access_commission_commission_accountant,commission.commission.accountant,model_commission_commission,account.group_account_manager,1,1,1,1
|
||||
|
1
ruodoo_commission/tests/__init__.py
Normal file
1
ruodoo_commission/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_commission
|
||||
185
ruodoo_commission/tests/test_commission.py
Normal file
185
ruodoo_commission/tests/test_commission.py
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCommission(AccountTestInvoicingCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# AccountTestInvoicingCommon работает под пользователем бухгалтерии
|
||||
# в контексте тестовой компании, но без прав на продажи и комиссии.
|
||||
# Добавляем нужные группы этому пользователю (компания сохраняется).
|
||||
cls.env.user.group_ids |= (
|
||||
cls.env.ref('sales_team.group_sale_salesman_all_leads')
|
||||
| cls.env.ref('ruodoo_commission.group_commission_manager')
|
||||
)
|
||||
cls.company = cls.company_data['company']
|
||||
cls.purchase_journal = cls.company_data['default_journal_purchase']
|
||||
cls.expense_account = cls.company_data['default_account_expense']
|
||||
|
||||
cls.agent_a = cls.env['res.partner'].create({'name': 'Агент А'})
|
||||
cls.agent_b = cls.env['res.partner'].create({'name': 'Агент Б'})
|
||||
cls.customer = cls.env['res.partner'].create({'name': 'Покупатель ООО'})
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'Товар А', 'type': 'consu', 'lst_price': 100.0,
|
||||
})
|
||||
|
||||
cls.commission_pct = cls.env['commission.commission'].create({
|
||||
'name': 'Комиссия 10%',
|
||||
'calc_type': 'percentage',
|
||||
'percentage': 10.0,
|
||||
'partner_id': cls.agent_a.id,
|
||||
'journal_id': cls.purchase_journal.id,
|
||||
'account_id': cls.expense_account.id,
|
||||
})
|
||||
cls.commission_code = cls.env['commission.commission'].create({
|
||||
'name': 'Комиссия по формуле',
|
||||
'calc_type': 'code',
|
||||
'amount_python_compute': 'result = price_subtotal * 0.05',
|
||||
'partner_id': cls.agent_b.id,
|
||||
'journal_id': cls.purchase_journal.id,
|
||||
'account_id': cls.expense_account.id,
|
||||
})
|
||||
|
||||
def _make_order(self, commissions=None, price_unit=1000.0, qty=1):
|
||||
line_vals = {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': qty,
|
||||
'price_unit': price_unit,
|
||||
}
|
||||
if commissions:
|
||||
line_vals['commission_ids'] = [(6, 0, [c.id for c in commissions])]
|
||||
return self.env['sale.order'].create({
|
||||
'partner_id': self.customer.id,
|
||||
'order_line': [(0, 0, line_vals)],
|
||||
})
|
||||
|
||||
# ---------------- Расчёт ----------------
|
||||
def test_percentage_amount(self):
|
||||
order = self._make_order([self.commission_pct], price_unit=1000.0)
|
||||
line = order.order_line
|
||||
# 10% от price_subtotal (1000) = 100
|
||||
self.assertAlmostEqual(line.commission_amount, 100.0)
|
||||
|
||||
def test_code_formula_amount(self):
|
||||
order = self._make_order([self.commission_code], price_unit=1000.0)
|
||||
# 5% от 1000 = 50
|
||||
self.assertAlmostEqual(order.order_line.commission_amount, 50.0)
|
||||
|
||||
def test_multiple_commissions_on_line(self):
|
||||
order = self._make_order(
|
||||
[self.commission_pct, self.commission_code], price_unit=1000.0,
|
||||
)
|
||||
# 100 + 50 = 150
|
||||
self.assertAlmostEqual(order.order_line.commission_amount, 150.0)
|
||||
self.assertAlmostEqual(order.amount_commission, 150.0)
|
||||
|
||||
def test_commission_not_in_amount_total(self):
|
||||
order = self._make_order([self.commission_pct], price_unit=1000.0)
|
||||
# amount_total не должен включать комиссию
|
||||
self.assertNotEqual(order.amount_total, 0.0)
|
||||
self.assertAlmostEqual(order.amount_total, order.amount_untaxed + order.amount_tax)
|
||||
|
||||
def test_bad_formula_raises(self):
|
||||
bad = self.env['commission.commission'].create({
|
||||
'name': 'Плохая формула',
|
||||
'calc_type': 'code',
|
||||
'amount_python_compute': 'result = undefined_var + 1',
|
||||
'partner_id': self.agent_a.id,
|
||||
'journal_id': self.purchase_journal.id,
|
||||
'account_id': self.expense_account.id,
|
||||
})
|
||||
# Сумма комиссии — хранимое вычисляемое поле, ошибка формулы
|
||||
# проявляется при вычислении (создании строки заказа).
|
||||
with self.assertRaises(UserError):
|
||||
order = self._make_order([bad], price_unit=1000.0)
|
||||
order.order_line._compute_commission_amount()
|
||||
|
||||
# ---------------- Начисление ----------------
|
||||
def test_accrue_creates_posted_bills_by_partner(self):
|
||||
order = self._make_order(
|
||||
[self.commission_pct, self.commission_code], price_unit=1000.0,
|
||||
)
|
||||
order.action_confirm()
|
||||
self.assertEqual(order.state, 'sale')
|
||||
order.action_accrue_commission()
|
||||
|
||||
bills = order.commission_bill_ids
|
||||
# Два разных получателя -> два билла
|
||||
self.assertEqual(len(bills), 2)
|
||||
self.assertEqual(set(bills.mapped('move_type')), {'in_invoice'})
|
||||
self.assertEqual(set(bills.mapped('state')), {'posted'})
|
||||
self.assertEqual(bills.mapped('commission_sale_order_id'), order)
|
||||
|
||||
bill_a = bills.filtered(lambda m: m.partner_id == self.agent_a)
|
||||
bill_b = bills.filtered(lambda m: m.partner_id == self.agent_b)
|
||||
self.assertAlmostEqual(bill_a.amount_total, 100.0)
|
||||
self.assertAlmostEqual(bill_b.amount_total, 50.0)
|
||||
# Счёт расходов проставлен на строках
|
||||
self.assertEqual(
|
||||
set(bill_a.invoice_line_ids.mapped('account_id')),
|
||||
{self.expense_account},
|
||||
)
|
||||
|
||||
def test_accrue_requires_confirmed(self):
|
||||
order = self._make_order([self.commission_pct])
|
||||
with self.assertRaises(UserError):
|
||||
order.action_accrue_commission()
|
||||
|
||||
def test_accrue_dedup(self):
|
||||
order = self._make_order([self.commission_pct])
|
||||
order.action_confirm()
|
||||
order.action_accrue_commission()
|
||||
with self.assertRaises(UserError):
|
||||
order.action_accrue_commission()
|
||||
|
||||
def test_accrue_skips_zero(self):
|
||||
zero = self.env['commission.commission'].create({
|
||||
'name': 'Комиссия 0%',
|
||||
'calc_type': 'percentage', 'percentage': 0.0,
|
||||
'partner_id': self.agent_a.id,
|
||||
'journal_id': self.purchase_journal.id,
|
||||
'account_id': self.expense_account.id,
|
||||
})
|
||||
order = self._make_order([zero])
|
||||
order.action_confirm()
|
||||
with self.assertRaises(UserError):
|
||||
order.action_accrue_commission()
|
||||
self.assertFalse(order.commission_bill_ids)
|
||||
|
||||
# ---------------- Отмена заказа ----------------
|
||||
def test_cancel_order_removes_bills(self):
|
||||
order = self._make_order([self.commission_pct])
|
||||
order.action_confirm()
|
||||
order.action_accrue_commission()
|
||||
self.assertTrue(order.commission_bill_ids)
|
||||
bill_ids = order.commission_bill_ids.ids
|
||||
|
||||
order.action_cancel()
|
||||
self.assertEqual(order.state, 'cancel')
|
||||
# Биллы удалены
|
||||
remaining = self.env['account.move'].search([('id', 'in', bill_ids)])
|
||||
self.assertFalse(remaining)
|
||||
|
||||
def test_cancel_order_no_bills_ok(self):
|
||||
order = self._make_order([self.commission_pct])
|
||||
order.action_confirm()
|
||||
# Без начисления отмена не должна падать
|
||||
order.action_cancel()
|
||||
self.assertEqual(order.state, 'cancel')
|
||||
|
||||
# ---------------- Смарт-кнопка ----------------
|
||||
def test_bill_count_and_action(self):
|
||||
order = self._make_order(
|
||||
[self.commission_pct, self.commission_code],
|
||||
)
|
||||
order.action_confirm()
|
||||
order.action_accrue_commission()
|
||||
self.assertEqual(order.commission_bill_count, 2)
|
||||
action = order.action_view_commission_bills()
|
||||
self.assertEqual(action['res_model'], 'account.move')
|
||||
self.assertIn('commission_sale_order_id', str(action['domain']))
|
||||
81
ruodoo_commission/views/commission_views.xml
Normal file
81
ruodoo_commission/views/commission_views.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============ Список видов комиссий ============ -->
|
||||
<record id="view_commission_commission_list" model="ir.ui.view">
|
||||
<field name="name">commission.commission.list</field>
|
||||
<field name="model">commission.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Комиссии агентам">
|
||||
<field name="name"/>
|
||||
<field name="calc_type"/>
|
||||
<field name="percentage" optional="show"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="journal_id"/>
|
||||
<field name="account_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ Форма вида комиссии ============ -->
|
||||
<record id="view_commission_commission_form" model="ir.ui.view">
|
||||
<field name="name">commission.commission.form</field>
|
||||
<field name="model">commission.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Комиссия агенту">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="В архиве" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="Например: Комиссия менеджеру 5%"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Расчёт">
|
||||
<field name="calc_type" widget="radio"/>
|
||||
<field name="percentage"
|
||||
invisible="calc_type != 'percentage'"
|
||||
required="calc_type == 'percentage'"/>
|
||||
</group>
|
||||
<group string="Проводки">
|
||||
<field name="partner_id"/>
|
||||
<field name="journal_id"/>
|
||||
<field name="account_id"/>
|
||||
<field name="analytic_account_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Формула" invisible="calc_type != 'code'">
|
||||
<field name="amount_python_compute" nolabel="1"
|
||||
widget="code" options="{'mode': 'python'}"
|
||||
required="calc_type == 'code'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ Действие и меню ============ -->
|
||||
<record id="action_commission_commission" model="ir.actions.act_window">
|
||||
<field name="name">Комиссии агентам</field>
|
||||
<field name="res_model">commission.commission</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Создайте вид комиссии агенту</p>
|
||||
<p>Комиссия рассчитывается как простой процент или по формуле,
|
||||
и привязывается к строкам заказа по аналогии с налогами.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_commission_commission"
|
||||
name="Комиссии агентам"
|
||||
parent="account.menu_finance"
|
||||
action="action_commission_commission"
|
||||
groups="ruodoo_commission.group_commission_viewer,account.group_account_manager"
|
||||
sequence="90"/>
|
||||
|
||||
</odoo>
|
||||
48
ruodoo_commission/views/sale_order_views.xml
Normal file
48
ruodoo_commission/views/sale_order_views.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_order_form_commission" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.commission</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Кнопка «Начислить комиссию»: только подтверждённый заказ,
|
||||
только группа «Начисляет комиссии» -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_accrue_commission" type="object"
|
||||
string="Начислить комиссию" class="btn-primary"
|
||||
invisible="state != 'sale'"
|
||||
groups="ruodoo_commission.group_commission_manager"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Смарт-кнопка «Комиссии» -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_commission_bills" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
groups="ruodoo_commission.group_commission_viewer">
|
||||
<field name="commission_bill_count" widget="statinfo"
|
||||
string="Комиссии"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Колонка «Комиссии» в строках заказа (рядом с налогами) -->
|
||||
<xpath expr="//field[@name='order_line']/list/field[@name='tax_ids']"
|
||||
position="after">
|
||||
<field name="commission_ids" widget="many2many_tags"
|
||||
optional="show"
|
||||
groups="ruodoo_commission.group_commission_viewer"/>
|
||||
<field name="commission_amount"
|
||||
groups="ruodoo_commission.group_commission_viewer"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Строка «ИТОГО КОМИССИЙ» рядом с итогами/налогами -->
|
||||
<xpath expr="//field[@name='tax_totals']" position="after">
|
||||
<field name="amount_commission"
|
||||
groups="ruodoo_commission.group_commission_viewer"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user