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,106 @@
=================================
РуОду — Контроль отгрузки заказов
=================================
.. |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|
Расширение продаж для контроля финансовых условий отгрузки и приёма
закрывающих документов. Заказ подтверждается к отгрузке только при
выполнении условий по балансу клиента; не прошедший проверку заказ
переводится в состояние «Ожидание оплаты».
Порт модуля ``gok_sale`` (Odoo 17) на платформу РуОду (Odoo 19).
**Содержание**
.. contents::
:local:
Возможности
===========
* Контроль отгрузки заказа по выбранному типу:
* *По подтверждению* — отгрузка разрешена всегда
* *По балансу* — долг клиента покрывает сумму заказа
* *По балансу + овердрафт* — с учётом лимита кредита
* Перевод не прошедшего проверку заказа в состояние «Ожидание оплаты»
с записью в чаттер
* Повторная проверка всех ожидающих заказов одним действием
(``validate_all``) — удобно для планировщика
* Активность «Допущено до отгрузки» на подтверждённом заказе
* Подсветка заказов с ценами ниже ранее согласованных для клиента
* Автоматическая генерация артикула (SKU) для новой номенклатуры
* Приём закрывающих документов по счетам (почта, ЭДО, оригинал, скан),
в том числе загрузка скан-копий клиентом из портала
Зависимости
===========
* ``sale_stock`` — заказы на продажу и отгрузка
* ``account`` — счета (``account.move``)
* ``portal`` — загрузка закрывающих документов из портала клиента
Настройка
=========
#. В заказе на продажу выберите *Тип контроля отгрузки* (по умолчанию
«По подтверждению»).
#. Для контроля по балансу заполните на контрагенте лимит кредита и
отслеживайте долг (поля выведены в карточку партнёра).
Использование
=============
#. Оформите заказ на продажу и нажмите «Подтвердить».
#. Если условия выполнены — заказ подтверждается штатно, создаётся
активность «Допущено до отгрузки».
#. Если условия не выполнены — заказ переходит в «Ожидание оплаты».
После поступления оплаты повторно подтвердите заказ вручную или
запустите повторную проверку всех ожидающих заказов.
#. Закрывающие документы регистрируются на счёте (поле «Закрывающий
документ») либо загружаются клиентом из портала счёта — тогда
создаётся запись со скан-копией.
Отличия от исходного модуля
===========================
При портировании исправлены дефекты оригинала:
* контроль баланса ведётся в контексте компании заказа
(``with_company``) без изменения текущей компании среды;
* тип активности задаётся data-записью, а не поиском по названию;
* последовательность SKU поставляется модулем;
* приём документов из портала вынесен на отдельный маршрут
``/my/closing_document`` (только для авторизованных пользователей).
Тестирование
============
Модуль покрыт тестами ``TransactionCase`` (генерация SKU, состояние
«Ожидание оплаты», проверки отгрузки по типам, повторная валидация,
подсветка цен, закрывающие документы). Запуск::
docker compose run --rm odoo odoo -d <db> -i ruodoo_sale_control \
--test-enable --test-tags=/ruodoo_sale_control --stop-after-init
Авторы
======
* MK.Lab, RuOdoo
Модуль сопровождается MK.Lab, RuOdoo.
.. image:: https://ruodoo.ru/logo.png
:alt: MK.Lab, RuOdoo
:target: https://ruodoo.ru
Модуль является частью решения РуОду.

View File

@@ -0,0 +1,3 @@
from . import controllers
from . import models

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
{
'name': 'РуОду — Контроль отгрузки заказов',
'version': '19.0.2026.09.27',
'summary': 'Контроль финансовых условий отгрузки и приём закрывающих документов',
'description': """
РуОду — Контроль отгрузки заказов
=================================
Расширение продаж для контроля отгрузки и электронного документооборота:
* Контроль финансовых условий заказа перед отгрузкой: по балансу,
по балансу с овердрафтом или по подтверждению. Заказ, не прошедший
проверку, переводится в состояние «Ожидание оплаты».
* Подсветка заказов, где цены ниже ранее согласованных для клиента.
* Автоматическая генерация артикула (SKU) для новой номенклатуры.
* Приём закрывающих документов по счетам (почта, ЭДО, оригинал, скан),
в том числе загрузка скан-копий из портала клиента.
Порт модуля `gok_sale` (Odoo 17) на платформу RuOdoo (Odoo 19).
""",
'author': 'MK.Lab, RuOdoo',
'website': 'https://ruodoo.ru',
'category': 'Sales',
'depends': [
'base',
'mail',
'sale_stock',
'account',
'portal',
],
'data': [
'security/ir.model.access.csv',
'data/ir_sequence_data.xml',
'data/mail_activity_data.xml',
'views/sale_control_views.xml',
'views/portal_templates.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
'maintainers': ['mklab', 'ruodoo'],
}

View File

@@ -0,0 +1,2 @@
from . import portal

View File

@@ -0,0 +1,35 @@
import base64
import datetime
import werkzeug
from odoo.http import request, route
from odoo.addons.portal.controllers.portal import CustomerPortal
class SaleControlPortal(CustomerPortal):
@route(['/my/closing_document'], type='http', auth='user', website=True)
def submit_closing_document(self, redirect=None, **post):
"""Приём закрывающего документа (скан) от клиента из портала."""
name = post.get('name')
invoice_id = post.get('invoice_id')
redirect_url = post.get('url') or '/my/invoices'
file_data = False
for uploaded in request.httprequest.files.getlist('image_1920'):
content = uploaded.read()
if content:
file_data = base64.b64encode(content)
if name and invoice_id and file_data:
request.env['doc.closeddoc'].sudo().create({
'name': name,
'date_doc': datetime.date.today(),
'invoice_id': int(invoice_id),
'channel': 'scan',
'file_inv': file_data,
})
return werkzeug.utils.redirect('/my/invoices')
return werkzeug.utils.redirect(redirect_url)

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_product_sku" model="ir.sequence">
<field name="name">Артикул номенклатуры (SKU)</field>
<field name="code">SKU</field>
<field name="prefix">SKU</field>
<field name="padding">6</field>
<field name="number_increment">1</field>
<field name="number_next">1</field>
</record>
</odoo>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_activity_shipment_allowed" model="mail.activity.type">
<field name="name">Допущено до отгрузки</field>
<field name="res_model">sale.order</field>
<field name="icon">fa-truck</field>
<field name="delay_count">0</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import sale_order
from . import product
from . import closed_doc
from . import account_move

View File

@@ -0,0 +1,8 @@
from odoo import fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
closed_doc_id = fields.Many2one('doc.closeddoc', string='Закрывающий документ')

View File

@@ -0,0 +1,21 @@
from odoo import fields, models
class ClosedDoc(models.Model):
_name = 'doc.closeddoc'
_description = 'Закрывающий документ по счёту'
name = fields.Char(string='Номер', required=True)
date_doc = fields.Date(string='Дата получения документа', required=True)
invoice_id = fields.Many2one('account.move', string='Инвойс', required=True)
channel = fields.Selection(
[
('post', 'Почта'),
('edi', 'ЭДО'),
('original', 'Поступил в офис'),
('scan', 'Скан-копия'),
],
string='Канал получения', default='post', required=True,
)
file_inv = fields.Binary(string='Файл: скан или XML')

View File

@@ -0,0 +1,34 @@
from odoo import api, models
class ProductProduct(models.Model):
_inherit = 'product.product'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('default_code'):
sku = self.env['ir.sequence'].next_by_code('SKU')
if sku:
vals['default_code'] = sku
return super().create(vals_list)
def set_sku(self):
for rec in self:
rec.default_code = self.env['ir.sequence'].next_by_code('SKU')
def get_fob_price(self):
"""Цена FOB из спецификации (BoM), иначе прайсовая цена.
Спецификации доступны только при установленном ``mrp``; без него
возвращаем прайсовую цену.
"""
self.ensure_one()
bom_ids = self['bom_ids'] if 'bom_ids' in self._fields else False
if bom_ids:
fob = bom_ids[0].bom_line_ids.filtered(
lambda ln: ln.product_id.name and 'FOB' in ln.product_id.name
)
return fob[0].product_id.lst_price if fob else self.lst_price
return self.lst_price

View File

@@ -0,0 +1,111 @@
from datetime import date
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
price_alarm = fields.Boolean(
string='Цены требуют проверки',
compute='_compute_price_alarm', store=True,
)
type_validation = fields.Selection(
[
('balance', 'По балансу'),
('overdraft', 'По балансу + овердрафт'),
('normal', 'По подтверждению'),
],
string='Тип контроля отгрузки', default='normal',
)
state = fields.Selection(
selection_add=[('wait', 'Ожидание оплаты')],
ondelete={'wait': 'set default'},
)
@api.depends('partner_id', 'order_line', 'order_line.price_unit', 'state')
def _compute_price_alarm(self):
SaleLine = self.env['sale.order.line']
for order in self:
if order.state != 'draft':
order.price_alarm = False
continue
alarm = False
partner = order.partner_id.parent_id or order.partner_id
for line in order.order_line:
if not line.product_id:
continue
previous_prices = SaleLine.search([
('order_id.partner_id', '=', partner.id),
('order_id.state', '=', 'sale'),
('product_id', '=', line.product_id.id),
]).sorted(
key=lambda ln: ln.order_id.date_order or fields.Datetime.now(),
reverse=True,
).mapped('price_unit')
# Сравниваем только с последней отгрузкой этому клиенту.
if previous_prices and line.price_unit < previous_prices[0]:
alarm = True
order.price_alarm = alarm
def _get_shipment_credit(self):
"""Долг и лимит кредита партнёра (с учётом головного контрагента)."""
self.ensure_one()
partner = self.partner_id.parent_id or self.partner_id
return partner.credit, partner.credit_limit
def _is_shipment_allowed(self):
"""Проверка финансовых условий отгрузки по выбранному типу контроля."""
self.ensure_one()
# Расчёт долга ведём в контексте компании заказа (без мутации env).
order = self.with_company(self.company_id)
credit, credit_limit = order._get_shipment_credit()
if self.type_validation == 'normal':
return True
if self.type_validation == 'balance':
return credit * (-1) >= self.amount_total
if self.type_validation == 'overdraft':
return credit * (-1) + credit_limit >= self.amount_total
return False
def action_confirm(self):
confirmable = self.env['sale.order']
for order in self:
if order._is_shipment_allowed():
order._log_shipment_activity()
# Заказ в ожидании оплаты вернём в черновик, чтобы штатный
# action_confirm принял его к подтверждению.
if order.state == 'wait':
order.state = 'draft'
confirmable |= order
elif order.state != 'wait':
order.message_post(
body='Проверка фин. условий оставила заказ в ожидании оплаты.'
)
order.state = 'wait'
if confirmable:
return super(SaleOrder, confirmable).action_confirm()
return True
def _log_shipment_activity(self):
self.ensure_one()
activity_type = self.env.ref(
'ruodoo_sale_control.mail_activity_shipment_allowed',
raise_if_not_found=False,
)
if not activity_type:
return
self.env['mail.activity'].create({
'res_model_id': self.env.ref('sale.model_sale_order').id,
'res_id': self.id,
'user_id': self.user_id.id,
'activity_type_id': activity_type.id,
'date_deadline': date.today(),
})
@api.model
def validate_all(self):
"""Повторно проверить все заказы в ожидании оплаты (cron/действие)."""
for order in self.search([('state', '=', 'wait')]):
order.action_confirm()

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_doc_closeddoc_user,doc.closeddoc.user,model_doc_closeddoc,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_doc_closeddoc_user doc.closeddoc.user model_doc_closeddoc base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from . import test_sale_control

View File

@@ -0,0 +1,112 @@
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSaleControl(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.company
cls.partner = cls.env['res.partner'].create({'name': 'Покупатель ООО'})
cls.product = cls.env['product.product'].create({
'name': 'Товар А',
'type': 'consu',
'lst_price': 100.0,
})
def _make_order(self, type_validation='normal', price_unit=100.0, qty=1):
return self.env['sale.order'].create({
'partner_id': self.partner.id,
'type_validation': type_validation,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': qty,
'price_unit': price_unit,
})],
})
# ------------------------------------------------------------------
def test_sku_generated_on_create(self):
product = self.env['product.product'].create({'name': 'Без артикула'})
self.assertTrue(product.default_code)
self.assertTrue(product.default_code.startswith('SKU'))
def test_sku_not_overwritten(self):
product = self.env['product.product'].create({
'name': 'Свой код', 'default_code': 'MY-CODE',
})
self.assertEqual(product.default_code, 'MY-CODE')
def test_set_sku_action(self):
old = self.product.default_code
self.product.set_sku()
self.assertNotEqual(self.product.default_code, old)
self.assertTrue(self.product.default_code.startswith('SKU'))
# ------------------------------------------------------------------
def test_wait_state_registered(self):
selection = dict(self.env['sale.order']._fields['state'].selection)
self.assertIn('wait', selection)
def test_confirm_normal_passes(self):
order = self._make_order(type_validation='normal')
order.action_confirm()
self.assertEqual(order.state, 'sale')
def test_confirm_balance_insufficient_waits(self):
# Нет кредита (баланс 0) — по балансу отгрузка не проходит.
order = self._make_order(type_validation='balance', price_unit=500.0)
order.action_confirm()
self.assertEqual(order.state, 'wait')
def test_shipment_activity_created(self):
order = self._make_order(type_validation='normal')
order.action_confirm()
activity_type = self.env.ref(
'ruodoo_sale_control.mail_activity_shipment_allowed'
)
activity = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('activity_type_id', '=', activity_type.id),
])
self.assertTrue(activity)
def test_validate_all_reprocesses(self):
order = self._make_order(type_validation='balance', price_unit=500.0)
order.action_confirm()
self.assertEqual(order.state, 'wait')
# Меняем на «по подтверждению» и повторно валидируем всё.
order.type_validation = 'normal'
self.env['sale.order'].validate_all()
self.assertEqual(order.state, 'sale')
def test_price_alarm_detects_lower_price(self):
# Первая подтверждённая продажа по 100.
first = self._make_order(type_validation='normal', price_unit=100.0)
first.action_confirm()
# Новый черновик по цене ниже -> тревога.
second = self._make_order(type_validation='normal', price_unit=80.0)
second._compute_price_alarm()
self.assertTrue(second.price_alarm)
# Черновик по цене не ниже -> тревоги нет.
third = self._make_order(type_validation='normal', price_unit=120.0)
third._compute_price_alarm()
self.assertFalse(third.price_alarm)
# ------------------------------------------------------------------
def test_closed_doc_link(self):
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
})
doc = self.env['doc.closeddoc'].create({
'name': 'ЗД-1',
'date_doc': '2026-01-15',
'invoice_id': invoice.id,
'channel': 'scan',
})
invoice.closed_doc_id = doc
self.assertEqual(invoice.closed_doc_id, doc)
self.assertEqual(doc.invoice_id, invoice)

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_invoice_closing_document"
name="Загрузка закрывающих документов"
inherit_id="account.portal_invoice_page">
<xpath expr="//div[hasclass('o_download_pdf')]" position="after">
<form action="/my/closing_document" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<li class="list-group-item flex-grow-1">
<div class="form-group">
<label class="col-form-label" for="closing_name">Номер</label>
<input type="hidden" name="invoice_id" t-att-value="invoice.id"/>
<input type="hidden" name="url" t-att-value="invoice.get_portal_url()"/>
<input type="text" id="closing_name" name="name"/>
</div>
<div class="form-group">
<label class="col-form-label" for="closing_file">Выбрать документы</label>
<input type="file" id="closing_file" name="image_1920"
data-show-upload="true" accept="image/*"/>
</div>
<div class="clearfix">
<button type="submit" class="btn btn-primary">
Загрузить закрывающие документы
<span class="fa fa-long-arrow-right"/>
</button>
</div>
</li>
</form>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ Заказ на продажу ============ -->
<record id="view_order_form_sale_control" model="ir.ui.view">
<field name="name">sale.order.form.sale.control</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='payment_term_id']" position="after">
<field name="type_validation"/>
</xpath>
<xpath expr="//header" position="inside">
<field name="price_alarm" invisible="1"/>
</xpath>
<xpath expr="//sheet" position="before">
<div class="alert alert-info" role="alert" style="margin-bottom:0px;"
invisible="not price_alarm">
Возможно, в этом заказе <b>цены ниже, чем должны быть!</b>
Заказ будет отправлен на согласование.
</div>
</xpath>
</field>
</record>
<!-- ============ Партнёр: финансовые показатели ============ -->
<record id="view_partner_form_sale_control" model="ir.ui.view">
<field name="name">res.partner.form.sale.control</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="credit_limit"/>
<field name="credit"/>
<field name="debit"/>
</xpath>
</field>
</record>
<!-- ============ Счёт: закрывающий документ ============ -->
<record id="view_move_form_sale_control" model="ir.ui.view">
<field name="name">account.move.form.sale.control</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='journal_id']" position="after">
<field name="closed_doc_id" context="{'default_invoice_id': id}"
domain="[('invoice_id','=',id)]"/>
</xpath>
</field>
</record>
<!-- ============ Закрывающие документы ============ -->
<record id="view_closed_doc_list" model="ir.ui.view">
<field name="name">doc.closeddoc.list</field>
<field name="model">doc.closeddoc</field>
<field name="arch" type="xml">
<list string="Закрывающие документы">
<field name="name"/>
<field name="date_doc"/>
<field name="invoice_id"/>
<field name="channel"/>
</list>
</field>
</record>
<record id="view_closed_doc_form" model="ir.ui.view">
<field name="name">doc.closeddoc.form</field>
<field name="model">doc.closeddoc</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="date_doc"/>
<field name="invoice_id"/>
<field name="channel"/>
<field name="file_inv" filename="name"/>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="action_closed_doc">
<field name="name">Закрывающие документы</field>
<field name="res_model">doc.closeddoc</field>
<field name="view_mode">list,form</field>
</record>
</odoo>