Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
70
directive_common/README.md
Normal file
70
directive_common/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Слой директив — Подключение к штатным моделям (Directive Common)
|
||||
**name:** directive_common
|
||||
**version:** 19.0.1.0.0
|
||||
**author:** RuOdoo
|
||||
|
||||
|
||||
### 1. Общее описание
|
||||
|
||||
Модуль-компаньон подключает слой директив `directive_layer` к штатным бизнес-объектам Odoo: задачам, заказам, счетам, платежам, складу и производству. Служит рабочим примером для разработчиков, которые хотят подключить директивы к своим объектам, и несёт крон-симулятор программных и машинных агентов. Демо-пользователи (`data/users.xml`) грузятся только в demo-БД.
|
||||
|
||||
|
||||
### 2. Установка
|
||||
|
||||
1. Убедитесь, что установлен модуль `directive_layer`
|
||||
|
||||
2. Разместите папку `directive_common` в директории `addons`
|
||||
|
||||
3. Перезапустите сервер Odoo
|
||||
|
||||
4. Обновите список приложений: **Настройки → Приложения → Обновить список**
|
||||
|
||||
5. Найдите **«Directive Common»** и установите
|
||||
|
||||
### 3. Функциональность
|
||||
|
||||
3.1 К каким объектам подключены директивы:
|
||||
|
||||
| Объект | Когда создаётся задание | Кто исполняет |
|
||||
|---|---|---|
|
||||
| Задача проекта | При переходе задачи в определённую стадию | Человек |
|
||||
| Заказ на продажу | При подтверждении заказа | WMS-агент (автоматически) |
|
||||
| Входящий счёт | При подтверждении счёта от поставщика | Аудит-бот (автоматически) |
|
||||
| Исходящий платёж | При переходе платежа в обработку | Банковский шлюз (автоматически) |
|
||||
| Перемещение склада | После подтверждения перемещения | AGV-робот (автоматически) |
|
||||
| Рабочий центр | Когда операция резки готова к запуску | IoT-оборудование (автоматически) |
|
||||
|
||||
3.2 Элементы на форме объекта:
|
||||
|
||||
На форме каждого из перечисленных объектов появляются три элемента:
|
||||
|
||||
- **Кнопка «Выбрать тип»** в шапке — назначает группу заданий для записи
|
||||
- **Кнопка «Директивы»** — показывает количество заданий, открывает список
|
||||
- **Поле «Исполнение»** — текущий сводный статус всех заданий по записи
|
||||
|
||||

|
||||
|
||||
После выполнения задания в чат записи автоматически поступает сообщение от исполнителя.
|
||||
|
||||
### Пример использования
|
||||
|
||||
### Открыть объект и назначить группу заданий
|
||||
|
||||
Перейдите в любой поддерживаемый объект, например **Продажи → Заказы**. Нажмите кнопку **«Выбрать тип»** в шапке формы и выберите группу шаблонов заданий.
|
||||
|
||||

|
||||
|
||||
### Выполнить бизнес-действие
|
||||
|
||||
Подтвердите заказ, переведите задачу в нужную стадию, проведите платёж и т.д. Задание создастся автоматически и появится в кнопке **«Директивы»**.
|
||||
|
||||
### Посмотреть список заданий
|
||||
|
||||
Нажмите кнопку **«Директивы»** на форме объекта — откроется список всех заданий с текущими стадиями, дедлайнами и исполнителями.
|
||||
|
||||

|
||||
|
||||
### Проверить результат в чате
|
||||
|
||||
После выполнения задания перейдите в чат записи — там появится сообщение от исполнителя с результатом работы.
|
||||
|
||||
217
directive_common/TECHNICAL.md
Normal file
217
directive_common/TECHNICAL.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Directive Common
|
||||
**name**: directive_common
|
||||
**version**: 19.0.1.0.0
|
||||
**author**: Mk.Lab
|
||||
**depends:** `directive_layer`, `base_user_role`, `sale`, `mail`, `account`, `account_payment`, `mrp`, `project`, `stock`
|
||||
|
||||
## Назначение
|
||||
|
||||
Модуль-компаньон подключает `directive_layer` к штатным моделям Odoo (`project.task`, `sale.order`, `account.move`, `account.payment`, `stock.picking`, `mrp.workorder` + `mrp.production`) и несёт крон-симулятор программных/машинных агентов. Служит референсом для разработчиков при подключении директив к новым моделям.
|
||||
|
||||
---
|
||||
|
||||
## Паттерн интеграции
|
||||
|
||||
Каждая интегрируемая модель подключается к `directive.mixin` через `_inherit`. Автогенерация директив настраивается **декларативно** через `directive.policy` (UI «Директивы → Политики»), а не через код модели-источника: метод-заглушка `_directive_generation_spec` удалён, доменная фильтрация перенесена в поле `directive.policy.domain_filter`.
|
||||
|
||||
```python
|
||||
class SomeModel(models.Model):
|
||||
_name = 'some.model'
|
||||
_inherit = ['some.model', 'directive.mixin']
|
||||
|
||||
# handler завершения директивы (путь B, вызывается из directive.directive.action_done)
|
||||
def action_executor_handler(self, payload: dict) -> bool:
|
||||
self.message_post(body=f"Результат: {payload.get('directive_name')}")
|
||||
return True
|
||||
```
|
||||
|
||||
### Два пути `on_callback`
|
||||
|
||||
**Путь A — политика `on_callback` на штатном методе источника.** Модель-источник
|
||||
переопределяет штатный метод Odoo через `super()` и **явно** вызывает публичный
|
||||
хелпер `self._directive_run_callback_policies("<имя_метода>")`. Это штатный
|
||||
override вместо прежнего monkey-patch — никакого `setattr`, `_register_hook`
|
||||
или оборачивания методов. Единственный штатный метод с этим путём —
|
||||
`stock.picking.button_validate` (см. ниже). Для демо-задачи этот же хелпер
|
||||
вызывается из собственного метода `project.task.action_demo_directive_callback`.
|
||||
|
||||
> **Намеренный отказ от `directive_skip_generation`.** Старый monkey-patch
|
||||
> wrapper уважал контекст `directive_skip_generation` и гард `if result is not
|
||||
> False`. Контекст использовался только внутри самого удалённого wrapper'а и ни
|
||||
> в одном бизнес-потоке — поэтому он не переносится. Гард «не False / не возврат
|
||||
> визарда» сохранён только там, где он нужен — в `button_validate`.
|
||||
|
||||
**Путь B — `handler_key` при `action_done`.** `directive.directive.action_done`
|
||||
читает `handler_key` из шаблона и вызывает одноимённый метод на записи-источнике
|
||||
с payload. Это штатный механизм `directive_layer`, не monkey-patch. Через него
|
||||
работают демо-handler'ы источников:
|
||||
|
||||
| handler_key | Модель-источник |
|
||||
|---|---|
|
||||
| `directive_on_done_post_message` | `project.task` |
|
||||
| `action_wms_response` | `sale.order` |
|
||||
| `action_audit_response` | `account.move` |
|
||||
| `action_bank_gateway_response` | `account.payment` |
|
||||
| `action_agv_callback` | `stock.picking` |
|
||||
| `action_iot_signal` | `mrp.workorder` |
|
||||
|
||||
---
|
||||
|
||||
## Интегрированные модели
|
||||
|
||||
### `project.task` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/project_task.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_write` по `stage_id` |
|
||||
| Тип исполнителя | `human` |
|
||||
|
||||
**Handler завершения (путь B) `directive_on_done_post_message(payload)`** — публикует в чат задачи сообщение: «Директива выполнена: {directive_name}».
|
||||
|
||||
**Демо-метод `action_demo_directive_callback()` (путь A)** — публикует «Demo callback executed» и затем **явно** вызывает `self._directive_run_callback_policies("action_demo_directive_callback")`. Используется тестом `on_callback` в `directive_layer`. Это не no-op переименование: именно эта строка-вызов заменяет прежний monkey-patch.
|
||||
|
||||
---
|
||||
|
||||
### `account.move` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/account_move.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_write` по `state` |
|
||||
| Переход | `draft` → `posted` |
|
||||
| Domain | `directive.policy.domain_filter`, напр. `[("move_type", "=", "in_invoice")]` (только входящие счета) |
|
||||
| Тип исполнителя | `software` (аудит-бот) |
|
||||
|
||||
**Handler завершения (путь B) `action_audit_response(payload)`** — публикует в чат счёта результат проверки с оценкой риска.
|
||||
|
||||
---
|
||||
|
||||
### `sale.order` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/sale_order.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_write` по `state` |
|
||||
| Переход | `draft` → `sale` |
|
||||
| Тип исполнителя | `software` (WMS-агент) |
|
||||
|
||||
**Handler завершения (путь B) `action_wms_response(payload)`** — публикует в чат заказа информацию о резервировании товара с указанием ячейки.
|
||||
|
||||
---
|
||||
|
||||
### `account.payment` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/account_payment.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_write` по `state` |
|
||||
| Переход | `draft` → `in_process` |
|
||||
| Domain | `directive.policy.domain_filter`, напр. `[("payment_type", "=", "outbound")]` (только исходящие) |
|
||||
| Тип исполнителя | `software` (банковский шлюз) |
|
||||
|
||||
**Handler завершения (путь B) `action_bank_gateway_response(payload)`** — публикует в чат платежа банковский референс в формате `REF-{id}-X99`.
|
||||
|
||||
---
|
||||
|
||||
### `stock.picking` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/stock_picking.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_callback` на методе `button_validate` |
|
||||
| Тип исполнителя | `software` (AGV-робот) |
|
||||
|
||||
**Путь A.** `button_validate` переопределён: `res = super().button_validate()`,
|
||||
затем — только если `res is not False` и результат не является возвратом
|
||||
визарда (`ir.actions.act_window` для backorder/immediate transfer) — вызывается
|
||||
`self._directive_run_callback_policies("button_validate")`. Гард сохраняет
|
||||
семантику прежнего wrapper'а: политика не срабатывает на промежуточном возврате
|
||||
визарда, когда перемещение ещё не проведено.
|
||||
|
||||
**Handler завершения (путь B) `action_agv_callback(payload)`** — публикует в чат перемещения информацию о доставке в зону производства.
|
||||
|
||||
---
|
||||
|
||||
### `mrp.workorder` + `directive.mixin`
|
||||
|
||||
**Файл:** `models/mrp_workorder.py`
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Событие (политика) | `on_write` по `state` |
|
||||
| Переход | `ready` → `progress` (через `button_start()`; в Odoo 19 состояния `pending` нет, дефолт — `ready`) |
|
||||
| Domain | `directive.policy.domain_filter`, напр. `[("name", "ilike", "резка")]` |
|
||||
| Тип исполнителя | `machine` (IoT/CNC) |
|
||||
|
||||
**Handler завершения (путь B) `action_iot_signal(payload)`** — публикует в чат рабочей операции телеметрию оборудования (температура шпинделя, вибрация) и обновляет заголовок производственного заказа.
|
||||
|
||||
---
|
||||
|
||||
### `mrp.production` (расширение без mixin)
|
||||
|
||||
**Файл:** `models/mrp_workorder.py`
|
||||
|
||||
Добавляет метод `action_choose_directive_group()` в модель производственного заказа. Метод берёт первый рабочий центр из `workorder_ids` и открывает визард `directive.choose.group.wizard` с `active_model='mrp.workorder'`. При отсутствии рабочих центров выводит предупреждение.
|
||||
|
||||
---
|
||||
|
||||
## Имитация программных агентов (cron)
|
||||
|
||||
**Метод:** `directive.directive._cron_simulate_system_agents()` (`models/directive_directive.py`)
|
||||
**Запись cron:** `data/cron.xml` → `ir_cron_simulate_system_agents`
|
||||
|
||||
Запись `ir.cron` перенесена сюда из `directive_layer` (прежний битый
|
||||
`ir_cron_generate_simulation_data`, указывавший на несуществующий метод,
|
||||
удалён). Крон указывает на `directive_layer.model_directive_directive`
|
||||
(модель живёт в `directive_layer`), по умолчанию `active=False` (симулятор,
|
||||
не включать в проде), интервал — 1 минута. Регистрируется в манифесте через
|
||||
ключ `data`.
|
||||
|
||||
Метод автоматически продвигает директивы программных и машинных исполнителей по стадиям:
|
||||
|
||||
| Текущая стадия | Действие |
|
||||
|---|---|
|
||||
| `planned` | `_set_stage('in_progress')` |
|
||||
| `in_progress` | `action_done()` |
|
||||
|
||||
Фильтр: `executor_type in ['software', 'machine']`, `stage_code in ['draft', 'planned', 'in_progress']`.
|
||||
|
||||
---
|
||||
|
||||
## Представления
|
||||
|
||||
**Файл:** `views/project_task_views.xml`
|
||||
|
||||
Добавляет единообразный набор элементов в формы шести объектов: `project.task`, `sale.order`, `account.move`, `account.payment`, `stock.picking`, `mrp.production`.
|
||||
|
||||
| Элемент | Расположение | Описание |
|
||||
|---|---|---|
|
||||
| Кнопка «Выбрать тип» | `header` | Открывает визард выбора группы шаблонов |
|
||||
| Кнопка «Директивы» | `oe_button_box` | Открывает список директив объекта |
|
||||
| Поле `directive_execution_status` | `sheet` | Текущий статус исполнения (readonly) |
|
||||
|
||||
Для задачи (`project.task`) дополнительно добавляется кнопка «Demo: Callback» для ручного тестирования механизма callback.
|
||||
|
||||
---
|
||||
|
||||
## Демо-пользователи
|
||||
|
||||
**Файл:** `data/users.xml` (ключ манифеста **`demo`** — грузятся только в demo-БД)
|
||||
|
||||
| XML ID | Логин | Имя | Бизнес-роль |
|
||||
|---|---|---|---|
|
||||
| `user_directive_executor` | `directive_executor` | Directive Executor | `directive_layer.role_directive_executor` |
|
||||
| `user_directive_requester` | `directive_requester` | Directive Requester | `directive_layer.role_directive_requester` |
|
||||
| `user_directive_dispatcher` | `directive_dispatcher` | Directive Dispatcher | `directive_layer.role_directive_dispatcher` |
|
||||
|
||||
Каждому пользователю назначается `base.group_user` плюс бизнес-роль через
|
||||
`role_line_ids`. Назначение роли триггерит `set_groups_from_roles`
|
||||
(`base_user_role`), который через `implied_ids` помещает пользователя в
|
||||
соответствующую техническую группу `_res_groups` (ACL и record rules слоя
|
||||
директив). Это демо-данные, не базовые справочники.
|
||||
1
directive_common/__init__.py
Normal file
1
directive_common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
17
directive_common/__manifest__.py
Normal file
17
directive_common/__manifest__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Directive Common",
|
||||
"version": "19.0.1.0.0",
|
||||
"summary": "Подключение слоя директив к штатным моделям (project, sale, account, stock, mrp) + крон-симулятор агентов",
|
||||
"author": "Mk.Lab",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["sale", "mail", "account", "account_payment", "mrp", "project", "directive_layer", "base_user_role", "stock"],
|
||||
"data": [
|
||||
"data/cron.xml",
|
||||
"views/project_task_views.xml",
|
||||
],
|
||||
"demo": [
|
||||
"data/users.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
14
directive_common/data/cron.xml
Normal file
14
directive_common/data/cron.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_simulate_system_agents" model="ir.cron">
|
||||
<field name="name">Директивы: симуляция системных агентов</field>
|
||||
<field name="model_id" ref="directive_layer.model_directive_directive"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_simulate_system_agents()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
32
directive_common/data/users.xml
Normal file
32
directive_common/data/users.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<odoo>
|
||||
<!--
|
||||
Демо-пользователи (загружаются ТОЛЬКО в demo-БД через ключ 'demo' манифеста,
|
||||
HIGH-1): каждому назначается бизнес-роль через role_line_ids. Назначение роли
|
||||
триггерит set_groups_from_roles -> пользователь попадает в соответствующую
|
||||
техническую _res_groups через implied_ids (см. directive_layer/security/roles.xml).
|
||||
Роли определены в directive_layer, поэтому ref('directive_layer.role_directive_*').
|
||||
-->
|
||||
<record id="user_directive_executor" model="res.users">
|
||||
<field name="name">Directive Executor</field>
|
||||
<field name="login">directive_executor</field>
|
||||
<field name="email">directive_executor@example.com</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="role_line_ids" eval="[(0, 0, {'role_id': ref('directive_layer.role_directive_executor')})]"/>
|
||||
</record>
|
||||
|
||||
<record id="user_directive_requester" model="res.users">
|
||||
<field name="name">Directive Requester</field>
|
||||
<field name="login">directive_requester</field>
|
||||
<field name="email">directive_requester@example.com</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="role_line_ids" eval="[(0, 0, {'role_id': ref('directive_layer.role_directive_requester')})]"/>
|
||||
</record>
|
||||
|
||||
<record id="user_directive_dispatcher" model="res.users">
|
||||
<field name="name">Directive Dispatcher</field>
|
||||
<field name="login">directive_dispatcher</field>
|
||||
<field name="email">directive_dispatcher@example.com</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="role_line_ids" eval="[(0, 0, {'role_id': ref('directive_layer.role_directive_dispatcher')})]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
BIN
directive_common/img_1.png
Normal file
BIN
directive_common/img_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
directive_common/img_2.png
Normal file
BIN
directive_common/img_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
directive_common/img_3.png
Normal file
BIN
directive_common/img_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
7
directive_common/models/__init__.py
Normal file
7
directive_common/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import project_task
|
||||
from . import sale_order
|
||||
from . import directive_directive
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
from . import mrp_workorder
|
||||
from . import stock_picking
|
||||
16
directive_common/models/account_move.py
Normal file
16
directive_common/models/account_move.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_name = "account.move"
|
||||
_inherit = ["account.move", "directive.mixin"]
|
||||
|
||||
def action_audit_response(self, payload: dict):
|
||||
"""
|
||||
Вызывается агентом-аудитором.
|
||||
"""
|
||||
for move in self:
|
||||
move.message_post(
|
||||
body=f"Audit Bot: Обнаружен риск, 30/100.<br/>"
|
||||
f"Директива #{payload.get('directive_id')} завершена с предупреждением."
|
||||
)
|
||||
17
directive_common/models/account_payment.py
Normal file
17
directive_common/models/account_payment.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_name = 'account.payment'
|
||||
_inherit = ["account.payment", "directive.mixin"]
|
||||
|
||||
def action_bank_gateway_response(self, payload: dict):
|
||||
for pay in self:
|
||||
bank_ref = f"REF-{pay.id}-X99"
|
||||
|
||||
pay.message_post(
|
||||
body=f"Bank Gateway: Платеж отправлен в банк.<br/>"
|
||||
f"Bank Reference: {bank_ref}<br/>"
|
||||
f"Комиссия агента: 0.00 у.е."
|
||||
)
|
||||
return True
|
||||
37
directive_common/models/directive_directive.py
Normal file
37
directive_common/models/directive_directive.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.addons.directive_layer.models.directive_constants import (
|
||||
STAGE_DRAFT,
|
||||
STAGE_PLANNED,
|
||||
STAGE_IN_PROGRESS,
|
||||
STAGE_DONE,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Directive(models.Model):
|
||||
_inherit = "directive.directive"
|
||||
|
||||
@api.model
|
||||
def _cron_simulate_system_agents(self):
|
||||
"""
|
||||
Этот метод имитирует работу внешних систем (WMS, Банк, AI).
|
||||
Запускается раз в минуту.
|
||||
"""
|
||||
system_directives = self.search([
|
||||
('executor_type', 'in', ['software', 'machine']),
|
||||
('stage_code', 'in', [STAGE_DRAFT, STAGE_PLANNED, STAGE_IN_PROGRESS]),
|
||||
('stage_code', '!=', STAGE_DONE)
|
||||
], limit=50)
|
||||
|
||||
for d in system_directives:
|
||||
|
||||
if d.stage_code == STAGE_PLANNED:
|
||||
d._set_stage(STAGE_IN_PROGRESS)
|
||||
_logger.info("Agent started directive %s", d.id)
|
||||
|
||||
elif d.stage_code == STAGE_IN_PROGRESS:
|
||||
d.action_done()
|
||||
_logger.info("Agent finished directive %s", d.id)
|
||||
68
directive_common/models/mrp_workorder.py
Normal file
68
directive_common/models/mrp_workorder.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class MrpWorkorder(models.Model):
|
||||
_name = "mrp.workorder"
|
||||
_inherit = ["mrp.workorder", "directive.mixin"]
|
||||
|
||||
def action_iot_signal(self, payload: dict):
|
||||
"""
|
||||
Метод-обработчик завершения директивы оборудованием.
|
||||
Записывает телеметрию в саму операцию и в головной заказ (MO).
|
||||
"""
|
||||
for wo in self:
|
||||
telemetry = payload.get('telemetry', {})
|
||||
temp = telemetry.get('temperature', 59)
|
||||
vibro = telemetry.get('vibration', 'Норма')
|
||||
|
||||
message = (
|
||||
f"IoT CNC [{wo.name}]: Операция завершена.<br/>"
|
||||
f"Температура шпинделя: {temp}°C<br/>"
|
||||
f"Вибрация: {vibro}."
|
||||
)
|
||||
|
||||
if wo.production_id:
|
||||
wo.production_id.message_post(
|
||||
body=f"Отчет по операции {wo.name}: {message}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
_inherit = 'mrp.production'
|
||||
|
||||
def action_choose_directive_group(self):
|
||||
"""Открывает визард выбора группы директив для первой операции заказа.
|
||||
|
||||
У `mrp.production` нет собственного directive.origin, поэтому визард
|
||||
открывается для первой операции (workorder_ids[:1]) — в типовом
|
||||
сценарии MO имеет одну ключевую операцию. Внешний контракт (кнопка на
|
||||
форме mrp.production открывает визард выбора группы) сохраняется.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
workorder = self.workorder_ids[:1]
|
||||
|
||||
if not workorder:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Внимание',
|
||||
'message': 'Сначала подтвердите заказ, чтобы появились операции (Work Orders)',
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": f"Назначить директивы для: {workorder.name}",
|
||||
"res_model": "directive.choose.group.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": {
|
||||
"active_model": "mrp.workorder",
|
||||
"active_id": workorder.id,
|
||||
},
|
||||
}
|
||||
19
directive_common/models/project_task.py
Normal file
19
directive_common/models/project_task.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_name = 'project.task'
|
||||
_inherit = ["project.task", "directive.mixin"]
|
||||
|
||||
def directive_on_done_post_message(self, payload: dict):
|
||||
for task in self:
|
||||
task.message_post(
|
||||
body=f"Директива выполнена: {payload.get('directive_name')})"
|
||||
)
|
||||
return True
|
||||
|
||||
def action_demo_directive_callback(self):
|
||||
for task in self:
|
||||
task.message_post(body="Demo callback executed")
|
||||
self._directive_run_callback_policies("action_demo_directive_callback")
|
||||
return True
|
||||
18
directive_common/models/sale_order.py
Normal file
18
directive_common/models/sale_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_name = 'sale.order'
|
||||
_inherit = ["sale.order", "directive.mixin"]
|
||||
|
||||
def action_wms_response(self, payload: dict):
|
||||
for order in self:
|
||||
bin_location = "ZONE-A-ROW-5"
|
||||
|
||||
order.message_post(
|
||||
body=f"WMS Агент: Товар зарезервирован.<br/>"
|
||||
f"Ячейка: {bin_location}<br/>"
|
||||
f"Директива #{payload.get('directive_id')} закрыта успешно."
|
||||
)
|
||||
|
||||
return True
|
||||
24
directive_common/models/stock_picking.py
Normal file
24
directive_common/models/stock_picking.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_name = 'stock.picking'
|
||||
_inherit = ["stock.picking", "directive.mixin"]
|
||||
|
||||
def button_validate(self):
|
||||
res = super().button_validate()
|
||||
# Сохраняем семантику старого monkey-patch wrapper: политика срабатывала
|
||||
# ТОЛЬКО если метод не вернул False, и НЕ должна срабатывать на промежуточном
|
||||
# возврате экшена-визарда (backorder / immediate transfer), т.к. в этот момент
|
||||
# перемещение ещё не проведено.
|
||||
if res is not False and not (isinstance(res, dict) and res.get("type") == "ir.actions.act_window"):
|
||||
self._directive_run_callback_policies("button_validate")
|
||||
return res
|
||||
|
||||
def action_agv_callback(self, payload: dict):
|
||||
for picking in self:
|
||||
picking.message_post(
|
||||
body="AGV Unit 01: Доставка в зону производства выполнена.<br/>"
|
||||
"Батарея: 84%."
|
||||
)
|
||||
return True
|
||||
1
directive_common/tests/__init__.py
Normal file
1
directive_common/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_mrp_workorder_scenario
|
||||
323
directive_common/tests/test_mrp_workorder_scenario.py
Normal file
323
directive_common/tests/test_mrp_workorder_scenario.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from odoo import fields
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged("standard", "at_install", "directive_logic")
|
||||
class TestMrpWorkorderScenario(TransactionCase):
|
||||
"""Сценарный тест директив на примере mrp.workorder (FR-10) + stock.picking
|
||||
путь A (FR-3.3) + крон-симулятор (MEDIUM-2/3, NIT-8).
|
||||
|
||||
Запускается после установки directive_common (tests/ находится автоматически),
|
||||
поэтому directive.mixin уже примешан в mrp.workorder/stock.picking и методы
|
||||
on_callback доступны — в отличие от тестов directive_layer, которые при at_install
|
||||
бегут до установки companion-модуля.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Stage = self.env["directive.stage"]
|
||||
|
||||
self.work_type = self.env["directive.work.type"].create(
|
||||
{"name": "CNC", "code": "cnc"}
|
||||
)
|
||||
|
||||
# Стадии: ищем сид-стадии directive_layer по коду, создаём недостающие.
|
||||
def _stage(code, name):
|
||||
st = Stage.search([("code", "=", code)], limit=1)
|
||||
if not st:
|
||||
st = Stage.create({"name": name, "code": code})
|
||||
return st
|
||||
|
||||
self.stage_draft = _stage("draft", "Draft")
|
||||
self.stage_confirmed = _stage("confirmed", "Confirmed")
|
||||
self.stage_planned = _stage("planned", "Planned")
|
||||
self.stage_in_progress = _stage("in_progress", "In Progress")
|
||||
self.stage_paused = _stage("paused", "Paused")
|
||||
self.stage_done = _stage("done", "Done")
|
||||
|
||||
# Шаблон сценарной директивы (путь B через handler_key=action_iot_signal).
|
||||
self.tmpl_cnc = self.env["directive.template"].create({
|
||||
"name": "CNC резка",
|
||||
"code": "tmpl_cnc_rezka",
|
||||
"work_type_id": self.work_type.id,
|
||||
"planned_hours": 1.0,
|
||||
"handler_key": "action_iot_signal",
|
||||
})
|
||||
|
||||
# Шаблон для крон-директивы — БЕЗ handler_key, уникальный code (Finding 3),
|
||||
# чтобы action_done в кроне был чистым переходом стадии.
|
||||
self.tmpl_agent = self.env["directive.template"].create({
|
||||
"name": "Agent task",
|
||||
"code": "tmpl_agent",
|
||||
"work_type_id": self.work_type.id,
|
||||
"planned_hours": 1.0,
|
||||
})
|
||||
|
||||
self.group = self.env["directive.template.group"].create({
|
||||
"name": "CNC Group",
|
||||
"template_line_ids": [(0, 0, {"template_id": self.tmpl_cnc.id})],
|
||||
})
|
||||
|
||||
# Сценарная политика: on_write state->progress, домен по имени операции.
|
||||
self.policy = self.env["directive.policy"].create({
|
||||
"name": "MRP резка on_write",
|
||||
"origin_model": "mrp.workorder",
|
||||
"event": "on_write",
|
||||
"trigger_field": "state",
|
||||
"trigger_value_char": "progress",
|
||||
"domain_filter": "[('name', 'ilike', 'резка')]",
|
||||
"mode": "create_once",
|
||||
"use_record_template_group": True,
|
||||
})
|
||||
|
||||
# MRP-фикстуры: продукт + BoM с двумя операциями (одна с «резка», одна без).
|
||||
self.product = self.env["product.product"].create({
|
||||
"name": "CNC Part",
|
||||
"type": "consu",
|
||||
"is_storable": True,
|
||||
})
|
||||
self.workcenter = self.env["mrp.workcenter"].create({"name": "CNC Workcenter"})
|
||||
|
||||
self.bom = self.env["mrp.bom"].create({
|
||||
"product_tmpl_id": self.product.product_tmpl_id.id,
|
||||
"product_qty": 1.0,
|
||||
"type": "normal",
|
||||
})
|
||||
self.op_rezka = self.env["mrp.routing.workcenter"].create({
|
||||
"name": "Операция резка",
|
||||
"workcenter_id": self.workcenter.id,
|
||||
"bom_id": self.bom.id,
|
||||
})
|
||||
self.op_other = self.env["mrp.routing.workcenter"].create({
|
||||
"name": "Сверловка",
|
||||
"workcenter_id": self.workcenter.id,
|
||||
"bom_id": self.bom.id,
|
||||
})
|
||||
|
||||
self.mo = self.env["mrp.production"].create({
|
||||
"product_id": self.product.id,
|
||||
"product_qty": 1.0,
|
||||
"bom_id": self.bom.id,
|
||||
})
|
||||
self.mo.action_confirm()
|
||||
|
||||
self.wo_rezka = self.mo.workorder_ids.filtered(
|
||||
lambda w: "резка" in (w.name or "").lower()
|
||||
)
|
||||
self.wo_other = self.mo.workorder_ids.filtered(
|
||||
lambda w: "резка" not in (w.name or "").lower()
|
||||
)
|
||||
self.assertTrue(self.wo_rezka, "Операция «резка» не создалась на MO")
|
||||
self.assertTrue(self.wo_other, "Контрольная операция (без «резка») не создалась")
|
||||
|
||||
# Группа шаблонов на обеих операциях (use_record_template_group=True).
|
||||
(self.wo_rezka | self.wo_other).write(
|
||||
{"directive_template_group_id": self.group.id}
|
||||
)
|
||||
|
||||
def _directives_for(self, workorder):
|
||||
return self.env["directive.directive"].search([
|
||||
("origin_id.res_model", "=", "mrp.workorder"),
|
||||
("origin_id.res_id", "=", workorder.id),
|
||||
("policy_id", "=", self.policy.id),
|
||||
])
|
||||
|
||||
# ------------------------------------------------------------------ FR-10.1
|
||||
def test_01_creation_on_button_start(self):
|
||||
"""FR-10.1: button_start() (ready->progress) создаёт ровно одну директиву
|
||||
для операции «резка»; контрольная операция — ни одной (негатив домена)."""
|
||||
self.assertEqual(self.wo_rezka.state, "ready")
|
||||
self.wo_rezka.button_start()
|
||||
self.assertEqual(self.wo_rezka.state, "progress")
|
||||
|
||||
directives = self._directives_for(self.wo_rezka)
|
||||
self.assertEqual(len(directives), 1, "Директива не создалась через on_write")
|
||||
self.assertEqual(directives.name, self.tmpl_cnc.name)
|
||||
self.assertEqual(directives.planned_hours, self.tmpl_cnc.planned_hours)
|
||||
|
||||
# Негатив домена: операция без «резка» не порождает директив.
|
||||
self.wo_other.button_start()
|
||||
self.assertEqual(self.wo_other.state, "progress")
|
||||
self.assertFalse(self._directives_for(self.wo_other),
|
||||
"Директива создалась вопреки domain_filter")
|
||||
|
||||
# create_once: повторный вход в progress не плодит дубль.
|
||||
self.wo_rezka.write({"state": "ready"})
|
||||
self.wo_rezka.write({"state": "progress"})
|
||||
self.assertEqual(len(self._directives_for(self.wo_rezka)), 1,
|
||||
"Сломался create_once")
|
||||
|
||||
# ------------------------------------------------------------------ FR-10.2
|
||||
def test_02_stage_movement_happy_path(self):
|
||||
"""FR-10.2: корректная последовательность стадий директивы."""
|
||||
self.wo_rezka.button_start()
|
||||
directive = self._directives_for(self.wo_rezka)
|
||||
self.assertEqual(len(directive), 1)
|
||||
self.assertEqual(directive.stage_code, "draft")
|
||||
|
||||
directive._set_stage("confirmed")
|
||||
self.assertEqual(directive.stage_code, "confirmed")
|
||||
|
||||
directive.action_assign_to_me()
|
||||
self.assertTrue(directive.executor_user_id)
|
||||
self.assertEqual(directive.stage_code, "confirmed")
|
||||
|
||||
directive.action_start()
|
||||
self.assertEqual(directive.stage_code, "in_progress")
|
||||
|
||||
directive.action_pause()
|
||||
self.assertEqual(directive.stage_code, "paused")
|
||||
|
||||
# Возобновление через сеттер, НЕ через action_start (гард action_start).
|
||||
directive._set_stage("in_progress")
|
||||
self.assertEqual(directive.stage_code, "in_progress")
|
||||
|
||||
directive.action_done()
|
||||
self.assertEqual(directive.stage_code, "done")
|
||||
|
||||
def test_03_invalid_transition_raises_usererror(self):
|
||||
"""FR-10.2 / FEAT-001: недопустимый переход даёт UserError (не TypeError)."""
|
||||
self.wo_rezka.button_start()
|
||||
directive = self._directives_for(self.wo_rezka)
|
||||
self.assertEqual(directive.stage_code, "draft")
|
||||
with self.assertRaises(UserError):
|
||||
directive._set_stage("done") # draft->done не разрешён
|
||||
|
||||
def test_04_action_guards(self):
|
||||
"""FR-10.2: action_assign_to_me требует confirmed/planned;
|
||||
action_start требует назначенного исполнителя."""
|
||||
self.wo_rezka.button_start()
|
||||
directive = self._directives_for(self.wo_rezka)
|
||||
|
||||
# Из draft нельзя назначить на себя.
|
||||
with self.assertRaises(UserError):
|
||||
directive.action_assign_to_me()
|
||||
|
||||
# confirmed без исполнителя — action_start падает.
|
||||
directive._set_stage("confirmed")
|
||||
with self.assertRaises(UserError):
|
||||
directive.action_start()
|
||||
|
||||
# --------------------------------------------------------- FR-10.2 cron
|
||||
def test_05_cron_simulate_system_agents(self):
|
||||
"""Крон двигает planned->in_progress, затем in_progress->done — только
|
||||
для выделенной software-директивы (MEDIUM-2/3, NIT-8).
|
||||
|
||||
Инвариант: сценарная директива из button_start остаётся
|
||||
executor_type='human' (дефолт) и крон её игнорирует — отдельная
|
||||
software-директива с tmpl_agent (без handler_key) используется для крона.
|
||||
"""
|
||||
self.wo_rezka.button_start()
|
||||
origin = self.wo_rezka.directive_origin_id
|
||||
self.assertTrue(origin)
|
||||
|
||||
cron_directive = self.env["directive.directive"].create({
|
||||
"name": "Agent directive",
|
||||
"deadline_at": fields.Datetime.now(),
|
||||
"planned_hours": 1.0,
|
||||
"work_type_id": self.work_type.id,
|
||||
"executor_type": "software",
|
||||
"origin_id": origin.id,
|
||||
"template_id": self.tmpl_agent.id,
|
||||
})
|
||||
|
||||
# Finding 2: директива создаётся в draft -> переводим в planned ПЕРВЫМ шагом.
|
||||
cron_directive._set_stage("planned")
|
||||
self.assertEqual(cron_directive.stage_code, "planned")
|
||||
|
||||
self.env["directive.directive"]._cron_simulate_system_agents()
|
||||
self.assertEqual(cron_directive.stage_code, "in_progress")
|
||||
|
||||
self.env["directive.directive"]._cron_simulate_system_agents()
|
||||
self.assertEqual(cron_directive.stage_code, "done")
|
||||
|
||||
# ------------------------------------------------------------------ FR-10.3
|
||||
def test_06_buttons_and_fields(self):
|
||||
"""FR-10.3: поля/методы на модели + кнопка только на форме mrp.production."""
|
||||
wo = self.wo_rezka
|
||||
self.assertIn("directive_execution_status", wo._fields)
|
||||
self.assertTrue(hasattr(wo, "action_choose_directive_group"))
|
||||
self.assertTrue(hasattr(wo, "action_open_directives"))
|
||||
|
||||
view = self.env.ref(
|
||||
"directive_common.mrp_production_form_directives_inherit"
|
||||
)
|
||||
arch = self.env["mrp.production"].get_view(view_id=view.id)["arch"]
|
||||
self.assertIn("action_choose_directive_group", arch,
|
||||
"Кнопка выбора группы не найдена на форме mrp.production")
|
||||
|
||||
# ------------------------------------------------------------------ FR-10.4
|
||||
def test_07_callback_path_b(self):
|
||||
"""FR-10.4: action_done -> action_iot_signal пишет телеметрию в MO (путь B,
|
||||
без monkey-patch)."""
|
||||
self.wo_rezka.button_start()
|
||||
directive = self._directives_for(self.wo_rezka)
|
||||
# draft -> confirmed -> in_progress (прямого draft->in_progress нет).
|
||||
directive._set_stage("confirmed")
|
||||
directive._set_stage("in_progress")
|
||||
|
||||
before = len(self.mo.message_ids)
|
||||
directive.action_done()
|
||||
self.assertEqual(directive.stage_code, "done")
|
||||
|
||||
self.mo.invalidate_recordset(["message_ids"])
|
||||
self.assertGreater(len(self.mo.message_ids), before,
|
||||
"action_iot_signal не записал сообщение в MO")
|
||||
|
||||
# ------------------------------------------------------------------ FR-3.3
|
||||
def test_08_stock_picking_callback_path_a(self):
|
||||
"""FR-3.3 путь A: stock.picking.button_validate -> helper создаёт директиву."""
|
||||
picking_type = self.env["stock.picking.type"].search(
|
||||
[("code", "=", "internal")], limit=1
|
||||
) or self.env["stock.picking.type"].search([], limit=1)
|
||||
|
||||
policy_picking = self.env["directive.policy"].create({
|
||||
"name": "Picking on_callback",
|
||||
"origin_model": "stock.picking",
|
||||
"event": "on_callback",
|
||||
"trigger_field": "button_validate",
|
||||
"mode": "create_once",
|
||||
"use_record_template_group": True,
|
||||
})
|
||||
|
||||
src = picking_type.default_location_src_id or self.env["stock.location"].search(
|
||||
[("usage", "=", "internal")], limit=1
|
||||
)
|
||||
dest = picking_type.default_location_dest_id or self.env["stock.location"].search(
|
||||
[("usage", "=", "internal")], limit=1
|
||||
)
|
||||
|
||||
picking = self.env["stock.picking"].create({
|
||||
"picking_type_id": picking_type.id,
|
||||
"location_id": src.id,
|
||||
"location_dest_id": dest.id,
|
||||
"directive_template_group_id": self.group.id,
|
||||
"move_ids": [(0, 0, {
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 1.0,
|
||||
"location_id": src.id,
|
||||
"location_dest_id": dest.id,
|
||||
})],
|
||||
})
|
||||
picking.action_confirm()
|
||||
for move in picking.move_ids:
|
||||
move.quantity = move.product_uom_qty
|
||||
picking.move_ids.picked = True
|
||||
|
||||
picking.button_validate()
|
||||
|
||||
def _picking_directives():
|
||||
return self.env["directive.directive"].search([
|
||||
("origin_id.res_model", "=", "stock.picking"),
|
||||
("origin_id.res_id", "=", picking.id),
|
||||
("policy_id", "=", policy_picking.id),
|
||||
])
|
||||
|
||||
self.assertEqual(len(_picking_directives()), 1,
|
||||
"Директива не создалась через button_validate (путь A)")
|
||||
|
||||
# create_once: повторный проход хелпера не плодит дубль.
|
||||
picking._directive_run_callback_policies("button_validate")
|
||||
self.assertEqual(len(_picking_directives()), 1,
|
||||
"Сломался create_once для stock.picking")
|
||||
198
directive_common/views/project_task_views.xml
Normal file
198
directive_common/views/project_task_views.xml
Normal file
@@ -0,0 +1,198 @@
|
||||
<odoo>
|
||||
<record id="project_task_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">project.task.form.directives.inherit</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//form/sheet/div[@class='oe_button_box']" position="inside">
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_directives"
|
||||
class="oe_stat_button"
|
||||
icon="fa-tasks"
|
||||
string="Директивы"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Исполнение">
|
||||
<field name="directive_execution_status" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_order_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.directives.inherit</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//form/sheet/div[@class='oe_button_box']" position="inside">
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_directives"
|
||||
class="oe_stat_button"
|
||||
icon="fa-tasks"
|
||||
string="Директивы"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Исполнение">
|
||||
<field name="directive_execution_status" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_move_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.form.directives.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//form/sheet/div[@class='oe_button_box']" position="inside">
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_directives"
|
||||
class="oe_stat_button"
|
||||
icon="fa-tasks"
|
||||
string="Директивы"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Исполнение">
|
||||
<field name="directive_execution_status" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_payment_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">account.payment.form.directives.inherit</field>
|
||||
<field name="model">account.payment</field>
|
||||
<field name="inherit_id" ref="account.view_account_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//form/sheet/div[@class='oe_button_box']" position="inside">
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_directives"
|
||||
class="oe_stat_button"
|
||||
icon="fa-tasks"
|
||||
string="Директивы"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Исполнение">
|
||||
<field name="directive_execution_status" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="stock_picking_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.form.directives.inherit</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//form/sheet/div[@class='oe_button_box']" position="inside">
|
||||
<button
|
||||
type="object"
|
||||
name="action_open_directives"
|
||||
class="oe_stat_button"
|
||||
icon="fa-tasks"
|
||||
string="Директивы"
|
||||
/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Исполнение">
|
||||
<field name="directive_execution_status" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="mrp_production_form_directives_inherit" model="ir.ui.view">
|
||||
<field name="name">mrp.production.form.directives.inherit</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button
|
||||
string="Выбрать тип"
|
||||
type="object"
|
||||
name="action_choose_directive_group"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_task_form_directive_demo" model="ir.ui.view">
|
||||
<field name="name">project.task.form.directive.demo</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button
|
||||
name="action_demo_directive_callback"
|
||||
type="object"
|
||||
string="Demo: Callback"
|
||||
class="btn-primary"
|
||||
/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user