Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from . import commission
from . import sale_order_line
from . import sale_order
from . import account_move

View 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='Заказ продаж, по которому начислен этот счёт комиссии агенту.',
)

View 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

View 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

View 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
)