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,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 Элементы на форме объекта:
На форме каждого из перечисленных объектов появляются три элемента:
- **Кнопка «Выбрать тип»** в шапке — назначает группу заданий для записи
- **Кнопка «Директивы»** — показывает количество заданий, открывает список
- **Поле «Исполнение»** — текущий сводный статус всех заданий по записи
![img_1.png](img_1.png)
После выполнения задания в чат записи автоматически поступает сообщение от исполнителя.
### Пример использования
### Открыть объект и назначить группу заданий
Перейдите в любой поддерживаемый объект, например **Продажи → Заказы**. Нажмите кнопку **«Выбрать тип»** в шапке формы и выберите группу шаблонов заданий.
![img_2.png](img_2.png)
### Выполнить бизнес-действие
Подтвердите заказ, переведите задачу в нужную стадию, проведите платёж и т.д. Задание создастся автоматически и появится в кнопке **«Директивы»**.
### Посмотреть список заданий
Нажмите кнопку **«Директивы»** на форме объекта — откроется список всех заданий с текущими стадиями, дедлайнами и исполнителями.
![img_3.png](img_3.png)
### Проверить результат в чате
После выполнения задания перейдите в чат записи — там появится сообщение от исполнителя с результатом работы.

View 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 слоя
директив). Это демо-данные, не базовые справочники.

View File

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

View 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,
}

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View 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

View 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')} завершена с предупреждением."
)

View 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

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

View 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,
},
}

View 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

View 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

View 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

View File

@@ -0,0 +1 @@
from . import test_mrp_workorder_scenario

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

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