Files
public/directive_layer/TECHNICAL.md

442 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) |