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,441 @@
# Directive Layer (Слой директив)
**name**: directive_layer
**version**: 19.0.1.0.0
**author**: Mk.Lab
**depends:** `base`, `web`, `base_user_role`, `project`
## Архитектура
```
directive_layer
├── directive.directive — основная сущность: директива
├── directive.stage — справочник стадий (7 стадий)
├── directive.work.type — справочник видов работ
├── directive.template — шаблон директивы
├── directive.template.group — группа шаблонов (пакет директив)
├── directive.template.group.line — строки группы
├── directive.origin — связка «модель + ID»
├── directive.policy — правило автогенерации директив
├── directive.mixin — AbstractModel для подключения к любой модели
├── res.users (расширение) — авто-установка домашней страницы исполнителя
└── ExecutorBoard (OWL) — рабочий стол исполнителя (fullscreen)
```
---
## Модели
### `directive.directive` — Директива
**Файл:** `models/directive.py`
| Поле | Тип | Описание |
|---|---|---|
| `name` | Char (required) | Название директивы |
| `active` | Boolean | Признак активности |
| `deadline_at` | Datetime (required) | Дедлайн исполнения |
| `planned_hours` | Float (required) | Трудоёмкость в часах |
| `load_percent` | Integer (default: 10) | Процент загрузки ресурса |
| `executor_type` | Selection | `human` / `machine` / `software` |
| `success_probability` | Float (default: 98.0) | Вероятность успешного выполнения, % |
| `resource_cost` | Float | Стоимость ресурса |
| `retry_policy` | Selection | `none` / `retry_3` / `backoff` |
| `priority` | Selection | `0` — низкий / `1` — нормальный / `2` — высокий |
| `work_type_id` | Many2one → `directive.work.type` (required) | Тип работ |
| `executor_role_id` | Many2one → `res.users.role` | Роль исполнителя |
| `requester_role_id` | Many2one → `res.users.role` | Роль заявителя |
| `stage_id` | Many2one → `directive.stage` | Стадия (вид диспетчера) |
| `stage_code` | Char (related, stored) | Технический код текущей стадии |
| `executor_stage_id` | Many2one → `directive.stage` | Стадия (вид исполнителя) |
| `requester_stage_id` | Many2one → `directive.stage` | Стадия (вид заявителя) |
| `executor_traffic_light` | Selection | Светофор исполнителя: `white/green/yellow/red/black` |
| `resource_traffic_light` | Selection | Светофор ресурса: `white/green/yellow/red/black` |
| `executor_user_id` | Many2one → `res.users` | Назначенный исполнитель |
| `origin_id` | Many2one → `directive.origin` (required, cascade) | Объект-источник |
| `template_id` | Many2one → `directive.template` | Шаблон директивы |
| `policy_id` | Many2one → `directive.policy` | Политика создания |
| `generator_key` | Char (index) | Ключ дедупликации для режима `create_once` |
| `predecessor_ids` | Many2many → `directive.directive` | Директивы-предшественники |
| `sequence` | Integer | Порядок в планировщике |
**Вычисляемые поля:**
| Поле | Описание |
|---|---|
| `deadline_display` | Дедлайн в формате `дд.мм.гг Ч:ММ` с учётом часового пояса пользователя |
| `time_left_display` | Оставшееся время в формате `Xд Yч Zм` (отрицательное, если просрочено) |
| `is_overdue` | Boolean: дедлайн прошёл |
| `priority_stars` | Строка ★ / ★★ / ★★★ |
**Порядок сортировки по умолчанию:** `deadline_at asc, priority desc, id desc`
#### Статусы
Допустимые переходы между стадиями:
| Текущая стадия | Допустимые следующие |
|---|---|
| `draft` | `confirmed`, `planned`, `cancelled` |
| `confirmed` | `planned`, `in_progress`, `cancelled` |
| `planned` | `in_progress`, `cancelled` |
| `in_progress` | `paused`, `done` |
| `paused` | `in_progress`, `cancelled` |
| `done` | — (финальная) |
| `cancelled` | — (финальная) |
Переходы контролируются методом `_check_stage_transition()`. Недопустимый переход вызывает `UserError`.
#### Методы
| Метод | Описание |
|---|---|
| `action_assign_to_me()` | Назначить на текущего пользователя (из стадий `confirmed` / `planned`) |
| `action_start()` | Перевести в `in_progress` |
| `action_pause()` | Перевести в `paused` (только из `in_progress`) |
| `action_done()` | Завершить директиву и вызвать callback в записи-источнике |
| `action_cancel()` | Отменить (обновляет все три stage-поля одновременно) |
| `action_open_delete_wizard()` | Открыть визард удаления |
| `_set_stage(code)` | Установить стадию по коду (обновляет `stage_id`, `executor_stage_id`, `requester_stage_id`) |
| `_get_origin_record()` | Получить запись-источник через `origin_id.res_model` + `origin_id.res_id` |
| `_build_payload(event)` | Сформировать словарь данных для передачи в callback |
| `action_quick_change_stage(id, code)` | `@api.model` — смена стадии по вызову из JS |
#### Механизм callback при завершении
При вызове `action_done()` выполняется следующая последовательность:
1. Стадия устанавливается в `done` через `_set_stage("done")`
2. Из шаблона директивы читается `handler_key` — имя метода в модели-источнике
3. Через `_get_origin_record()` получается запись-источник
4. Метод `handler_key` вызывается на записи-источнике с payload
**Состав payload:**
`event`, `directive_id`, `directive_name`, `template_id`, `template_code`, `work_type_id`, `executor_user_id`, `planned_start_at`, `planned_end_at`, `stage_code`, `priority`, `load_percent`
---
### `directive.stage` — Стадии
**Файл:** `models/directive_stage.py`
| Поле | Тип | Описание |
|---|---|---|
| `name` | Char (required) | Название стадии |
| `code` | Char (required, unique) | Технический код |
| `is_hidden` | Boolean | Скрыть колонку в канбане |
**Предустановленные стадии** (`data/directive_stage_data.xml`, `noupdate="1"`):
| Код | Название | Скрыта в канбане |
|---|---|---|
| `draft` | Черновик | Нет |
| `confirmed` | Подтверждена | Нет |
| `planned` | Запланирована | Нет |
| `in_progress` | В работе | Нет |
| `paused` | На паузе | Нет |
| `done` | Выполнена | Да |
| `cancelled` | Отменена | Да |
---
### `directive.template` — Шаблон директивы
**Файл:** `models/directive_template.py`
| Поле | Тип | Описание |
|---|---|---|
| `name` | Char (required) | Название шаблона |
| `code` | Char (required, unique) | Технический код |
| `work_type_id` | Many2one → `directive.work.type` | Тип работ |
| `planned_hours` | Float (required) | Трудоёмкость в часах |
| `load_percent` | Integer | Процент загрузки ресурса |
| `priority` | Selection 0/1/2 | Приоритет директив по этому шаблону |
| `executor_role_id` | Many2one → `res.users.role` | Роль исполнителя по умолчанию |
| `requester_role_id` | Many2one → `res.users.role` | Роль заявителя по умолчанию |
| `default_stage_id` | Many2one → `directive.stage` | Стартовая стадия (по умолчанию: `draft`) |
| `handler_key` | Char | Имя метода в модели-источнике, вызываемого при завершении директивы |
| `description` | Text | Описание и инструкции для исполнителя |
---
### `directive.template.group` — Группа шаблонов
**Файл:** `models/directive_template_group.py`
| Поле | Тип | Описание |
|---|---|---|
| `name` | Char (required) | Название группы |
| `code` | Char | Технический код |
| `applicable_model` | Char | Техническое имя модели, к которой применяется группа |
| `template_line_ids` | One2many → `directive.template.group.line` | Строки группы |
**`directive.template.group.line`:**
| Поле | Тип | Описание |
|---|---|---|
| `group_id` | Many2one → `directive.template.group` (cascade) | Родительская группа |
| `sequence` | Integer | Порядок создания директив из этой строки |
| `template_id` | Many2one → `directive.template` | Шаблон директивы |
---
### `directive.origin` — Источник директив
**Файл:** `models/directive_origin.py`
Хранит пару `(res_model, res_id)` и агрегирует все директивы для данного объекта. На каждую запись создаётся ровно один `directive.origin`.
| Поле | Тип | Описание |
|---|---|---|
| `res_model` | Char (required, index) | Техническое имя модели Odoo |
| `res_id` | Integer (required, index) | ID записи |
| `res_ref` | Reference (computed, stored) | Прямая ссылка на запись |
| `directive_ids` | One2many → `directive.directive` | Все директивы источника |
| `display_name` | Char (computed, stored) | Отображаемое имя в формате `res_model,res_id` |
---
### `directive.policy` — Политика автогенерации
**Файл:** `models/directive_policy.py`
| Поле | Тип | Описание |
|---|---|---|
| `name` | Char (required) | Название политики |
| `active` | Boolean | Политика активна |
| `sequence` | Integer | Порядок проверки политик |
| `origin_model` | Char (required) | Модель, к которой применяется политика |
| `event` | Selection | Тип события: `manual` / `on_create` / `on_write` / `on_stage` / `on_cron` / `on_callback` |
| `mode` | Selection | `create_once` — однократно; `create_each_time` — при каждом срабатывании |
| `use_record_template_group` | Boolean | Брать группу шаблонов из поля записи, а не из фиксированной |
| `template_group_id` | Many2one → `directive.template.group` | Фиксированная группа шаблонов |
| `trigger_field` | Char | Имя поля (для `on_write`) или метода (для `on_callback`) |
| `trigger_value_int` | Integer | Ожидаемое значение для Many2one/Integer-поля (ID записи или число) |
| `trigger_value_char` | Char | Ожидаемое значение для Char/Selection-поля (например `mrp.workorder.state = 'progress'`) |
| `domain_filter` | Char | Odoo-домен (строка), применяется к записи-источнику перед генерацией (вкл. `on_callback`) |
#### Логика `_policy_should_fire()`
1. Если задан `domain_filter` — он вычисляется через `safe_eval` и проверяется
`record.filtered_domain(...)` для **всех** событий (включая `on_callback`).
Непарсящийся домен логируется (`_logger.error`) и даёт `False` (fail-soft —
бизнес-операция записи не падает).
2. Для события `on_callback` (при пройденном домене) — возвращает `True`
(сам факт вызова = триггер).
3. Для остальных событий читает значение `trigger_field` у записи и сравнивает:
Many2one — по `.id` с `trigger_value_int`; Char/Selection — с
`trigger_value_char` (если он пуст — fail-loud: `False` + warning, без
проваливания в целочисленную ветку); Integer/число — с `trigger_value_int`.
#### Логика `apply_to_records()`
Для каждой записи: в режиме `create_once` проверяет, нет ли уже директив от этой политики для данного `origin_id`. Если нет — определяет группу шаблонов и вызывает `_directive_create_from_group()`.
---
### `directive.mixin` — Миксин для моделей
**Файл:** `models/directive_mixin.py`
**Тип:** `models.AbstractModel`
Подключается к модели через `_inherit`:
```python
class ProjectTask(models.Model):
_name = 'project.task'
_inherit = ['project.task', 'directive.mixin']
```
#### Поля, добавляемые в модель
| Поле | Тип | Описание |
|---|---|---|
| `directive_origin_id` | Many2one → `directive.origin` (readonly) | Origin-запись данного объекта |
| `directive_ids` | One2many (related) | Все директивы объекта |
| `directive_execution_status` | Char (computed, stored) | Текстовый статус исполнения |
| `directive_template_group_id` | Many2one → `directive.template.group` | Группа шаблонов объекта |
#### Методы
**`_ensure_directive_origin()`** — находит или создаёт `directive.origin` для записи. Вызывается при `create` и `write`.
**`write()`** — перехватывает изменения полей. Фиксирует значения до изменения, выполняет `super().write()`, затем проверяет политики с `event='on_write'` и вызывает `apply_to_records()` при совпадении.
**`_directive_run_callback_policies(callback_name)`** — публичный хелпер для
политик `on_callback`. Вызывается **явно** из override штатного метода
модели-источника (в `directive_common`) после `super()`. Находит активные
политики с `event='on_callback'` и `trigger_field == callback_name` для
`origin_model = self._name` и прогоняет `apply_to_records(self)` под `sudo()`
(автогенерация — системное действие). Заменяет прежний monkey-patch механизм:
удалены `_register_hook`, `_directive_register_callback_wrappers`,
`functools`-оборачивание, `setattr(cls, ...)` и прямой SQL к
`information_schema`.
**`_compute_directive_execution_status()`** — формирует строку статуса на основе наиболее приоритетной активной директивы. Приоритет стадий: `paused > in_progress > planned/confirmed > draft`. Результат: `"{stage_name}, до {nearest_deadline}, ожидается завершение к {expected_end}"`.
**`action_choose_directive_group()`** — открывает визард `directive.choose.group.wizard`.
**`action_open_directives()`** — открывает список директив, отфильтрованных по `origin_id` текущей записи.
**`_directive_create_from_group(template_group, generator_key, policy)`** — создаёт директивы по строкам группы в порядке `sequence`. Каждая директива получает параметры из шаблона, ссылку на `origin` и `policy`.
> **Прим.** Прежние методы-заглушки `_directive_generation_spec`,
> `_directive_on_assign/start/pause/done`, `_directive_update/cancel/delete`
> удалены как мёртвый код. Автогенерация настраивается декларативно через
> `directive.policy`; завершение директивы обрабатывается через `handler_key`
> (путь B) и явный хелпер `_directive_run_callback_policies` (путь A) в
> `directive_common`.
---
### `res.users` — Расширение
**Файл:** `models/res_users.py`
При создании или изменении пользователя проверяет вхождение в группу `role_directive_executor_res_groups`. При совпадении устанавливает `action_id = action_executor_dashboard_client` — рабочий стол исполнителя становится домашней страницей пользователя.
---
## Визарды
### `directive.choose.group.wizard`
**Файл:** `wizard/directive_choose_group_wizard.py`
| Поле | Тип | Описание |
|---|---|---|
| `template_group_id` | Many2one → `directive.template.group` (required) | Выбранная группа шаблонов |
Метод `action_confirm()` берёт `active_model` и `active_id` из контекста и записывает выбранную группу в поле `directive_template_group_id` исходной записи. Сами директивы не создаются — только сохраняется группа.
### `directive.delete.wizard`
**Файл:** `wizard/directive_delete_wizard.py`
| Режим | Действие |
|---|---|
| `archive` | Мягкое удаление: `active=False` |
| `delete` | Жёсткое удаление: `unlink()` |
---
## Представления и меню
**Файл:** `views/directive_views.xml`, `views/directive_policy_views.xml`
### Структура меню
```
Директивы (menu_directive_root)
├── Все директивы (menu_directive_all)
│ → action_directive_all: directive.directive (kanban/list/form)
├── Политики (menu_directive_policy_action)
│ → action_directive_policy: directive.policy (list/form)
└── Настройки (menu_directive_settings_root)
├── Источники директив → directive.origin (list/form)
├── Статусы директив → directive.stage (list/form)
├── Виды работ → directive.work.type (list/form)
├── Шаблоны директив → directive.template (list/form)
└── Группы шаблонов директив → directive.template.group (list/form)
```
### Рабочий стол исполнителя
Зарегистрирован как `ir.actions.client` (`action_executor_dashboard_client`) с тегом `directive_layer.executor_dashboard_action`. Открывается в режиме `fullscreen`. Не является пунктом меню — устанавливается как `action_id` (домашняя страница) пользователям с ролью исполнителя через `res.users`.
---
## `ExecutorBoard` (OWL-компонент)
**Файлы:** `static/src/components/executor_board/executor_board.{js,xml,scss}`
Зарегистрирован как `ir.actions.client` с тегом `directive_layer.executor_dashboard_action`, открывается в режиме `fullscreen`.
**Логика загрузки данных:**
1. Определяет `userId` из `session.user_id` или `storeData`
2. Загружает все стадии (`directive.stage`)
3. Загружает директивы с фильтром `executor_user_id = userId`
4. Строит колонки канбана: стадия и директивы с совпадающим `executor_stage_id`
**Быстрые действия на карточке директивы:**
| Действие | Переход |
|---|---|
| Начать | `action_quick_change_stage(id, 'in_progress')` |
| Пауза | `action_quick_change_stage(id, 'paused')` |
| Завершить | `action_quick_change_stage(id, 'done')` |
После каждого действия данные перезагружаются.
---
## Безопасность
### Роли (`security/roles.xml`)
Каждая роль создаётся одновременно в двух системах:
- `res.users.role` (модуль `base_user_role`) — бизнес-роль для назначения через интерфейс ролей и для полей `executor_role_id`/`requester_role_id`
- `res.groups` (`*_res_groups`) — техническая группа для record rules, `ir.model.access` и `has_group`
Бизнес-роль связана со своей технической группой через `implied_ids`
(`[(4, ref('role_directive_<роль>_res_groups'))]`). Назначение бизнес-роли
пользователю вызывает `set_groups_from_roles` (`base_user_role`), который
проставляет `role.implied_ids` → пользователь попадает в `_res_groups`.
Без этой связи назначение роли не давало бы технической группы и доступ
был бы закрыт (fail-shut). Поэтому `res.groups` определены в `roles.xml`
ВЫШЕ `res.users.role` — чтобы `ref(...)` в `implied_ids` резолвился, —
а `roles.xml` грузится в манифесте ДО `ir.model.access.csv`.
| Роль | Назначение |
|---|---|
| `role_directive_executor` | Исполнитель: видит свои директивы, берёт в работу, завершает |
| `role_directive_requester` | Заявитель: создаёт и отслеживает директивы |
| `role_directive_dispatcher` | Диспетчер: назначает исполнителей, управляет очередью |
### Record Rules (`security/directives_rules.xml`)
Одно **global**-правило `rule_directive_base_access` с тернарным bypass для
диспетчера:
- диспетчер (член `role_directive_dispatcher_res_groups`) → `[(1,'=',1)]` (видит все);
- остальные → свои/ролевые директивы (OR по `executor_user_id` / `create_uid` /
`executor_role_id` / `requester_role_id`);
- пользователь без ролей директив → ноль строк (fail-closed, NFR-6).
**Почему одно global-правило, а не два.** В Odoo global-правила комбинируются
по AND и только ограничивают; group-правила комбинируются по OR и лишь
расширяют доступ в пределах global. Поэтому вынести «диспетчер видит всё» в
отдельное group-правило нельзя: `global(own) AND group(all) = own`, диспетчер
потерял бы чужие директивы. Единое global-правило с `has_group`-bypass — это
сознательный, поведение-сохраняющий выбор, а не незавершённый рефактор.
### Права доступа (`security/ir.model.access.csv`)
CRUD-права распределены по техническим группам ролей (не по `base.group_user`):
- `directive.directive` — диспетчер `1,1,1,1`; исполнитель `1,1,0,0` (двигает свои,
не создаёт/не удаляет); заявитель `1,0,0,0`.
- `directive.policy` — диспетчер `1,1,1,1`; исполнитель/заявитель только `read`
(автогенерация идёт под `sudo()`, поэтому `create` рядовому пользователю не нужен).
- Справочники (`stage`, `work.type`, `template`, `template.group`,
`template.group.line`, `origin`) — `read` всем трём ролям, полный CRUD только диспетчеру.
- Визарды (`choose.group.wizard`, `delete.wizard`) — `read,create` по ролям.
- Bounded `base.group_user` **read-only** на `directive.directive`, `directive.origin`,
`directive.stage` — чтобы related-поля (`directive_ids`, `directive_execution_status`)
на формах-источниках грузились у любого internal user. Выборку это не расширяет:
global record rule оставляет role-less пользователю ноль строк. На `directive.policy`
такой read НЕ добавлен (ни одна форма-источник не читает поле политики).
---
## Тесты
**Файл:** `tests/test_directive_layer.py`
| Что покрывает |
|---|
| Создание директив через шаблоны и группы |
| Статусная машина: допустимые и недопустимые переходы (включая `UserError` при недопустимом переходе) |
| Политики: срабатывание `on_write` |
| Политики: срабатывание `on_callback` через явный хелпер `_directive_run_callback_policies` (на `project.task`) |
| Модель безопасности: диспетчер видит все директивы, исполнитель — свои, пользователь без ролей — ноль (fail-closed) |