Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC
This commit is contained in:
145
directive_layer/README.md
Normal file
145
directive_layer/README.md
Normal file
@ -0,0 +1,145 @@
|
||||
# Слой директив (Directive Layer)
|
||||
**name:** `directive_layer`
|
||||
**version:** 19.0.1.0.0
|
||||
**author:** Mk.Lab
|
||||
|
||||
---
|
||||
|
||||
## 1. Общее описание
|
||||
|
||||
**Директива** — это структурированное задание, которое система Odoo автоматически создаёт и назначает исполнителю при наступлении бизнес-события. Директива несёт в себе информацию о типе работ, ответственных ролях, дедлайне, трудоёмкости и ожидаемом результате.
|
||||
|
||||
---
|
||||
|
||||
## 2. Установка
|
||||
|
||||
1. Убедитесь, что установлен модуль `base_user_role`
|
||||
2. Скопируйте папку `directive_layer` в директорию `addons`
|
||||
3. Перезапустите сервер Odoo
|
||||
4. Перейдите в **Настройки → Приложения → Обновить список**
|
||||
5. Найдите **«Directive Layer»** и нажмите **Установить**
|
||||
|
||||
После установки автоматически создаются 7 стадий заданий.
|
||||
|
||||
## 3. Функциональность
|
||||
|
||||
- Автоматическое создание заданий при наступлении событий в системе
|
||||
- Три типа исполнителей: человек, программный агент, оборудование
|
||||
- Статусная машина с контролируемыми переходами между стадиями
|
||||
- Визуальные светофоры для контроля состояния исполнителя и ресурсов
|
||||
- Шаблоны и группы шаблонов для быстрого создания пакетов заданий
|
||||
- Настройка правил автоматической генерации без программирования
|
||||
- Рабочий стол исполнителя — полноэкранный дашборд для работы с заданиями
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 4. Роли пользователей
|
||||
|
||||
| Роль | Что может делать |
|
||||
|---|---|
|
||||
| Исполнитель | Видит свои задания, берёт в работу, завершает |
|
||||
| Заявитель | Создаёт и отслеживает задания |
|
||||
| Диспетчер | Назначает исполнителей, управляет очередью |
|
||||
|
||||
При назначении роли **Исполнителя** система автоматически устанавливает «Рабочий стол исполнителя» домашней страницей пользователя.
|
||||
|
||||
---
|
||||
|
||||
## 5. Жизненный цикл директивы
|
||||
|
||||
```
|
||||
Черновик
|
||||
↓ подтверждение
|
||||
Подтверждена
|
||||
↓ планирование
|
||||
Запланирована
|
||||
↓ старт
|
||||
В работе ←→ На паузе
|
||||
↓ завершение
|
||||
Выполнена
|
||||
```
|
||||
|
||||
При завершении задания система автоматически уведомляет объект-источник (задачу, заказ и т.д.) о результате.
|
||||
|
||||
---
|
||||
|
||||
## Как это выглядит в интерфейсе
|
||||
|
||||
### На форме объекта (задача, заказ, счёт...)
|
||||
|
||||
После подключения к объекту на его форме появляются три элемента:
|
||||
|
||||
**Кнопка «Выбрать тип»** в шапке формы — позволяет выбрать группу шаблонов заданий для данного объекта.
|
||||
|
||||
**Кнопка «Директивы»** — показывает количество заданий, связанных с записью. Нажмите, чтобы открыть список.
|
||||
|
||||
**Поле «Текущий статус исполнения»** — автоматически вычисляемый текст с наиболее приоритетным активным заданием, ближайшим дедлайном и ожидаемой датой завершения.
|
||||
|
||||

|
||||
---
|
||||
|
||||
### Рабочий стол исполнителя по задачам
|
||||
|
||||
Полноэкранный канбан-дашборд для пользователей с ролью «Исполнитель». Показывает только задания, назначенные текущему пользователю, сгруппированные по стадиям.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Меню «Директивы»
|
||||
|
||||
В главном меню появляется раздел **Директивы** с двумя пунктами:
|
||||
|
||||
- **Все директивы** — список всех заданий в системе (канбан и список)
|
||||
- **Политики** — правила автоматического создания заданий без программирования
|
||||
|
||||
В подразделе **Директивы → Настройки** доступны справочники:
|
||||
|
||||
- **Шаблоны директив** — словарь шаблонов с типом работ, трудоёмкостью и ролями
|
||||
- **Группы шаблонов директив** — наборы шаблонов для пакетного создания заданий
|
||||
- **Виды работ** — справочник видов работ
|
||||
- **Статусы директив** — справочник стадий заданий
|
||||
- **Источники директив** — список объектов, к которым подключены задания
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Как создавать задания: три способа
|
||||
|
||||
### 1. Вручную
|
||||
|
||||
Откройте нужный объект → кнопка **«Выбрать тип»** → выберите группу шаблонов → задания создаются немедленно.
|
||||
|
||||

|
||||
|
||||
### 2. Автоматически через политику (без кода)
|
||||
|
||||
Перейдите в **Директивы → Политики** и создайте правило:
|
||||
- Выберите объект (задача, заказ и т.д.)
|
||||
- Укажите событие: при изменении поля, при создании записи или при вызове действия
|
||||
- Задайте условие срабатывания
|
||||
- Выберите группу шаблонов
|
||||
|
||||

|
||||
|
||||
После сохранения задания будут создаваться автоматически при наступлении события.
|
||||
|
||||
### 3. Через код (для разработчиков)
|
||||
|
||||
Разработчик подключает модуль к нужной модели через `directive.mixin`. Подробнее — в технической документации и модулю.
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
| Модуль | Назначение |
|
||||
|---|---|
|
||||
| `base` | Базовый Odoo |
|
||||
| `web` | Фронтенд |
|
||||
| `base_user_role` | Управление ролями пользователей |
|
||||
| `project` | Модуль проектов |
|
||||
|
||||
|
||||
392
directive_layer/TECHNICAL.md
Normal file
392
directive_layer/TECHNICAL.md
Normal file
@ -0,0 +1,392 @@
|
||||
# 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 | Признак активности |
|
||||
| `is_simulation` | 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 | Ожидаемое значение поля (ID записи для Many2one-полей) |
|
||||
|
||||
#### Логика `_policy_should_fire()`
|
||||
|
||||
Для события `on_callback` — всегда возвращает `True`. Для остальных событий: читает значение `trigger_field` у записи и сравнивает с `trigger_value_int`. Для Many2one-полей сравнивается `.id`.
|
||||
|
||||
#### Логика `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_register_callback_wrappers()`** — вызывается при старте сервера (`_register_hook`). Оборачивает методы модели через `functools.wraps` для обработки политик с `event='on_callback'`. Флаг `_directive_wrapped` предотвращает повторное оборачивание.
|
||||
|
||||
**`_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_on_assign(self, payload)` | При назначении исполнителя |
|
||||
| `_directive_on_start(self, payload)` | При переходе в `in_progress` |
|
||||
| `_directive_on_pause(self, payload)` | При переходе в `paused` |
|
||||
| `_directive_on_done(self, payload)` | При завершении директивы |
|
||||
| `_directive_generation_spec(self)` | Возвращает список спецификаций для автогенерации (альтернатива Policy UI) |
|
||||
|
||||
---
|
||||
|
||||
### `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`) — для назначения через интерфейс ролей
|
||||
- `res.groups` — для использования в record rules и `ir.model.access`
|
||||
|
||||
| Роль | Назначение |
|
||||
|---|---|
|
||||
| `role_directive_executor` | Исполнитель: видит свои директивы, берёт в работу, завершает |
|
||||
| `role_directive_requester` | Заявитель: создаёт и отслеживает директивы |
|
||||
| `role_directive_dispatcher` | Диспетчер: назначает исполнителей, управляет очередью |
|
||||
|
||||
### Record Rules (`security/directives_rules.xml`)
|
||||
|
||||
Ограничивают видимость директив по ролям пользователя.
|
||||
|
||||
### Права доступа (`security/ir.model.access.csv`)
|
||||
|
||||
CRUD-права для всех моделей модуля, распределённые по ролям.
|
||||
|
||||
---
|
||||
|
||||
## Тесты
|
||||
|
||||
**Файл:** `tests/test_directive_layer.py`
|
||||
|
||||
| Что покрывает |
|
||||
|---|
|
||||
| Создание директив через шаблоны и группы |
|
||||
| Статусная машина: допустимые и недопустимые переходы |
|
||||
| Политики: срабатывание `on_write` |
|
||||
| Миксин на модели `project.task` |
|
||||
3
directive_layer/__init__.py
Normal file
3
directive_layer/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import tests
|
||||
from . import wizard
|
||||
31
directive_layer/__manifest__.py
Normal file
31
directive_layer/__manifest__.py
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Directive Layer",
|
||||
"version": "19.0.1.0.0",
|
||||
"summary": "Базовый слой директив",
|
||||
"category": "Productivity",
|
||||
"website": "",
|
||||
"author": "Mk.lab",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["base", "web", "base_user_role", "project"],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"directive_layer/static/src/scss/directive_kanban.scss",
|
||||
"directive_layer/static/src/components/executor_board/executor_board.js",
|
||||
"directive_layer/static/src/components/executor_board/executor_board.xml",
|
||||
"directive_layer/static/src/components/executor_board/executor_board.scss",
|
||||
],
|
||||
},
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"security/roles.xml",
|
||||
"security/directives_rules.xml",
|
||||
"data/directive_stage_data.xml",
|
||||
"views/directive_views.xml",
|
||||
"views/directive_actions.xml",
|
||||
"views/directive_policy_views.xml",
|
||||
"views/cron.xml",
|
||||
"wizard/directive_wizards.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": True,
|
||||
}
|
||||
43
directive_layer/data/directive_stage_data.xml
Normal file
43
directive_layer/data/directive_stage_data.xml
Normal file
@ -0,0 +1,43 @@
|
||||
<odoo noupdate="1">
|
||||
<record id="directive_stage_draft" model="directive.stage">
|
||||
<field name="name">Черновик</field>
|
||||
<field name="code">draft</field>
|
||||
<field name="is_hidden">False</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_confirmed" model="directive.stage">
|
||||
<field name="name">Подтверждена</field>
|
||||
<field name="code">confirmed</field>
|
||||
<field name="is_hidden">False</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_planned" model="directive.stage">
|
||||
<field name="name">Запланирована</field>
|
||||
<field name="code">planned</field>
|
||||
<field name="is_hidden">False</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_in_progress" model="directive.stage">
|
||||
<field name="name">В работе</field>
|
||||
<field name="code">in_progress</field>
|
||||
<field name="is_hidden">False</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_paused" model="directive.stage">
|
||||
<field name="name">На паузе</field>
|
||||
<field name="code">paused</field>
|
||||
<field name="is_hidden">False</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_done" model="directive.stage">
|
||||
<field name="name">Выполнена</field>
|
||||
<field name="code">done</field>
|
||||
<field name="is_hidden">True</field>
|
||||
</record>
|
||||
|
||||
<record id="directive_stage_cancelled" model="directive.stage">
|
||||
<field name="name">Отменена</field>
|
||||
<field name="code">cancelled</field>
|
||||
<field name="is_hidden">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
BIN
directive_layer/img_1.png
Normal file
BIN
directive_layer/img_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
directive_layer/img_2.png
Normal file
BIN
directive_layer/img_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
directive_layer/img_3.png
Normal file
BIN
directive_layer/img_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
directive_layer/img_4.png
Normal file
BIN
directive_layer/img_4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
directive_layer/img_5.png
Normal file
BIN
directive_layer/img_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
9
directive_layer/models/__init__.py
Normal file
9
directive_layer/models/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from . import directive_stage
|
||||
from . import directive_work_type
|
||||
from . import directive_origin
|
||||
from . import directive_mixin
|
||||
from . import directive_template
|
||||
from . import directive_policy
|
||||
from . import directive_template_group
|
||||
from . import directive
|
||||
from . import res_users
|
||||
362
directive_layer/models/directive.py
Normal file
362
directive_layer/models/directive.py
Normal file
@ -0,0 +1,362 @@
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
TRAFFIC_LIGHT = [
|
||||
("white", "White"),
|
||||
("black", "Black"),
|
||||
("green", "Green"),
|
||||
("yellow", "Yellow"),
|
||||
("red", "Red"),
|
||||
]
|
||||
|
||||
class Directive(models.Model):
|
||||
_name = "directive.directive"
|
||||
_description = "Directive"
|
||||
_order = "deadline_at asc, priority desc, id desc"
|
||||
|
||||
is_simulation = fields.Boolean(
|
||||
string="Simulation Task",
|
||||
default=False,
|
||||
help="Флаг для отделения тестовых данных от реальных"
|
||||
)
|
||||
|
||||
PRIORITY = [
|
||||
("0", "Low"),
|
||||
("1", "Normal"),
|
||||
("2", "High"),
|
||||
]
|
||||
|
||||
name = fields.Char(string="Название директивы", required=True)
|
||||
active = fields.Boolean(string="Активна", default=True)
|
||||
|
||||
deadline_at = fields.Datetime(string="Дедлайн исполнения", required=True)
|
||||
planned_hours = fields.Float(string="Трудоёмкость", required=True)
|
||||
load_percent = fields.Integer(string="Процент загрузки", default=10)
|
||||
|
||||
executor_type = fields.Selection([
|
||||
('human', 'Человек'),
|
||||
('machine', 'Оборудование / Станки'),
|
||||
('software', 'Программный агент / API')
|
||||
], string="Тип исполнителя", default='human', required=True)
|
||||
|
||||
success_probability = fields.Float(string="Вероятность успеха (%)", default=98.0)
|
||||
|
||||
resource_cost = fields.Float(string="Стоимость ресурса (у.е.)", default=0.0)
|
||||
|
||||
retry_policy = fields.Selection([
|
||||
('none', 'Остановиться и звать человека'),
|
||||
('retry_3', 'Повторить 3 раза'),
|
||||
('backoff', 'Экспоненциальная задержка')
|
||||
], string="Политика отказа", default='none')
|
||||
|
||||
priority = fields.Selection(
|
||||
selection=PRIORITY,
|
||||
string="Приоритет",
|
||||
default="0",
|
||||
required=True,
|
||||
)
|
||||
|
||||
work_type_id = fields.Many2one(
|
||||
"directive.work.type",
|
||||
string="Тип работ",
|
||||
required=True,
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
executor_role_id = fields.Many2one(
|
||||
"res.users.role",
|
||||
string="Роль исполнителя",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
requester_role_id = fields.Many2one(
|
||||
"res.users.role",
|
||||
string="Роль заявителя",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
stage_id = fields.Many2one(
|
||||
"directive.stage",
|
||||
string="Статус (для диспетчера)",
|
||||
default=lambda self: self.env["directive.stage"].search([("code", "=", "draft")], limit=1),
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
group_expand="_group_expand_stage_id"
|
||||
)
|
||||
|
||||
state = fields.Char(
|
||||
related="stage_id.code"
|
||||
)
|
||||
|
||||
executor_stage_id = fields.Many2one(
|
||||
"directive.stage",
|
||||
string="Статус для исполнителя",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
requester_stage_id = fields.Many2one(
|
||||
"directive.stage",
|
||||
string="Статус для заявителя",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
executor_traffic_light = fields.Selection(
|
||||
selection=TRAFFIC_LIGHT,
|
||||
string="Светофор контроля исполнителя",
|
||||
default="white",
|
||||
required=True,
|
||||
)
|
||||
resource_traffic_light = fields.Selection(
|
||||
selection=TRAFFIC_LIGHT,
|
||||
string="Светофор контроля ресурсов",
|
||||
default="white",
|
||||
required=True,
|
||||
)
|
||||
|
||||
planned_start_at = fields.Datetime(string="Планируемая дата начала")
|
||||
planned_end_at = fields.Datetime(string="Планируемая дата окончания")
|
||||
|
||||
executor_user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="Назначенный исполнитель",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
)
|
||||
|
||||
origin_id = fields.Many2one(
|
||||
"directive.origin",
|
||||
string="Источник (породивший документ)",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
|
||||
template_id = fields.Many2one(
|
||||
"directive.template",
|
||||
string="Шаблон директивы",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
)
|
||||
|
||||
policy_id = fields.Many2one(
|
||||
"directive.policy",
|
||||
string="Policy",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
)
|
||||
|
||||
stage_code = fields.Char(related="stage_id.code", store=True, readonly=True)
|
||||
|
||||
generator_key = fields.Char(index=True)
|
||||
|
||||
deadline_display = fields.Char(compute="_compute_ui_fields")
|
||||
time_left_display = fields.Char(compute="_compute_ui_fields")
|
||||
is_overdue = fields.Boolean(compute="_compute_ui_fields")
|
||||
priority_stars = fields.Char(compute="_compute_ui_fields")
|
||||
|
||||
sequence = fields.Integer(string="Planner Sequence", default=10)
|
||||
predecessor_ids = fields.Many2many('directive.directive', 'directive_dependencies_rel', 'directive_id',
|
||||
'predecessor_id', string="Dependencies")
|
||||
|
||||
@api.depends("deadline_at", "priority")
|
||||
def _compute_ui_fields(self):
|
||||
tz = self.env.user.tz or "UTC"
|
||||
tzinfo = pytz.timezone(tz)
|
||||
now = datetime.now(tzinfo)
|
||||
|
||||
for rec in self:
|
||||
if rec.deadline_at:
|
||||
dt = fields.Datetime.context_timestamp(rec.with_context(tz=tz), rec.deadline_at)
|
||||
rec.deadline_display = dt.strftime("%d.%m.%y %-H:%M")
|
||||
rec.is_overdue = dt < now
|
||||
|
||||
delta = dt - now
|
||||
total_min = int(delta.total_seconds() // 60)
|
||||
if total_min < 0:
|
||||
total_min = abs(total_min)
|
||||
sign = "-"
|
||||
else:
|
||||
sign = ""
|
||||
|
||||
days = total_min // (60 * 24)
|
||||
hours = (total_min % (60 * 24)) // 60
|
||||
mins = total_min % 60
|
||||
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(f"{days}д")
|
||||
if hours:
|
||||
parts.append(f"{hours}ч")
|
||||
parts.append(f"{mins}м")
|
||||
|
||||
rec.time_left_display = f"{sign}{' '.join(parts)}"
|
||||
else:
|
||||
rec.deadline_display = ""
|
||||
rec.time_left_display = ""
|
||||
rec.is_overdue = False
|
||||
|
||||
p = int(rec.priority or 0)
|
||||
rec.priority_stars = "★" * (p + 1)
|
||||
|
||||
def write(self, vals):
|
||||
if "stage_id" in vals:
|
||||
new_stage = self.env["directive.stage"].browse(vals["stage_id"])
|
||||
for rec in self:
|
||||
rec._check_stage_transition(rec.stage_id, new_stage)
|
||||
|
||||
return super().write(vals)
|
||||
|
||||
def _check_stage_transition(self, from_stage, to_stage):
|
||||
from_code = (from_stage.code or "").strip()
|
||||
to_code = (to_stage.code or "").strip()
|
||||
|
||||
allowed = {
|
||||
"draft": {"confirmed", "planned", "cancelled"},
|
||||
"confirmed": {"planned", "in_progress", "cancelled"},
|
||||
"planned": {"in_progress", "cancelled"},
|
||||
"in_progress": {"paused", "done"},
|
||||
"paused": {"in_progress", "cancelled"},
|
||||
"done": set(),
|
||||
"cancelled": set(),
|
||||
}
|
||||
|
||||
if from_code and to_code and to_code not in allowed.get(from_code, set()):
|
||||
raise UserError("Нельзя переместить из '%s' в '%s'.") % (from_stage.name, to_stage.name)
|
||||
|
||||
@api.model
|
||||
def _group_expand_stage_id(self, stages, domain, order=None):
|
||||
return stages.search([], order=order or "id asc")
|
||||
|
||||
def action_cancel(self):
|
||||
cancelled = self.env["directive.stage"].search([("code", "=", "cancelled")], limit=1)
|
||||
if not cancelled:
|
||||
raise UserError("Stage with code 'cancelled' not found.")
|
||||
self.write({
|
||||
"stage_id": cancelled.id,
|
||||
"executor_stage_id": cancelled.id,
|
||||
"requester_stage_id": cancelled.id,
|
||||
})
|
||||
return True
|
||||
|
||||
def action_open_delete_wizard(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Удаление директивы",
|
||||
"res_model": "directive.delete.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": {
|
||||
"active_model": self._name,
|
||||
"active_id": self.id,
|
||||
"active_ids": self.ids,
|
||||
},
|
||||
}
|
||||
|
||||
def _action_archive(self):
|
||||
self.write({"active": False})
|
||||
return True
|
||||
|
||||
def _action_delete_hard(self):
|
||||
self.unlink()
|
||||
return True
|
||||
|
||||
def _build_payload(self, event: str) -> dict:
|
||||
self.ensure_one()
|
||||
tmpl = self.template_id
|
||||
return {
|
||||
"event": event,
|
||||
"directive_id": self.id,
|
||||
"directive_name": self.name,
|
||||
"template_id": tmpl.id if tmpl else None,
|
||||
"template_code": tmpl.code if tmpl else None,
|
||||
"work_type_id": self.work_type_id.id if self.work_type_id else None,
|
||||
"executor_user_id": self.executor_user_id.id if self.executor_user_id else None,
|
||||
"planned_start_at": self.planned_start_at,
|
||||
"planned_end_at": self.planned_end_at,
|
||||
"stage_code": self.stage_id.code if self.stage_id else None,
|
||||
"priority": self.priority,
|
||||
"load_percent": self.load_percent,
|
||||
}
|
||||
|
||||
def _stage_by_code(self, code: str):
|
||||
stage = self.env["directive.stage"].search([("code", "=", code)], limit=1)
|
||||
if not stage:
|
||||
raise UserError(f"Directive stage '{code}' not found.")
|
||||
return stage
|
||||
|
||||
def _set_stage(self, code: str):
|
||||
stage = self._stage_by_code(code)
|
||||
self.write({
|
||||
"stage_id": stage.id,
|
||||
"executor_stage_id": stage.id,
|
||||
"requester_stage_id": stage.id,
|
||||
})
|
||||
|
||||
def _get_origin_record(self):
|
||||
self.ensure_one()
|
||||
origin = self.origin_id
|
||||
if not origin or not origin.res_model or not origin.res_id:
|
||||
return self.env[self._name].browse() # empty
|
||||
return self.env[origin.res_model].browse(origin.res_id).exists()
|
||||
|
||||
def action_assign_to_me(self):
|
||||
for rec in self:
|
||||
if rec.executor_user_id:
|
||||
raise UserError("Директива уже назначена исполнителю.")
|
||||
if rec.stage_code not in ("confirmed", "planned"):
|
||||
raise UserError("Назначить на себя можно только подтвержденную или запланированную директиву.")
|
||||
rec.write({"executor_user_id": rec.env.user.id})
|
||||
return True
|
||||
|
||||
def action_start(self):
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
if not rec.executor_user_id:
|
||||
raise UserError("Нельзя начать директиву без назначенного исполнителя.")
|
||||
if rec.stage_code not in ("confirmed", "planned"):
|
||||
raise UserError("Начать можно только подтвержденную или запланированную директиву.")
|
||||
if rec.planned_start_at and now < rec.planned_start_at:
|
||||
raise UserError("Начать нельзя раньше планируемой даты начала.")
|
||||
rec._set_stage("in_progress")
|
||||
return True
|
||||
|
||||
def action_pause(self):
|
||||
for rec in self:
|
||||
if rec.stage_code != "in_progress":
|
||||
raise UserError("Поставить на паузу можно только директиву в состоянии 'В работе'.")
|
||||
rec._set_stage("paused")
|
||||
return True
|
||||
|
||||
def action_done(self):
|
||||
for rec in self:
|
||||
if rec.stage_code != "in_progress":
|
||||
raise UserError("Завершить можно только директиву в состоянии 'В работе'.")
|
||||
rec._set_stage("done")
|
||||
|
||||
tmpl = rec.template_id
|
||||
handler_key = (tmpl.handler_key or "").strip() if tmpl else ""
|
||||
if handler_key:
|
||||
origin_record = rec._get_origin_record()
|
||||
if origin_record:
|
||||
payload = rec._build_payload("done")
|
||||
method = getattr(origin_record, handler_key, None)
|
||||
if not method:
|
||||
raise UserError(f"Handler '{handler_key}' не найден в модели источника ({origin_record._name}).")
|
||||
method(payload)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def action_quick_change_stage(self, directive_id, target_code):
|
||||
""" Публичный метод для вызова из JS """
|
||||
directive = self.browse(directive_id)
|
||||
if directive.exists():
|
||||
directive._set_stage(target_code)
|
||||
return True
|
||||
return False
|
||||
330
directive_layer/models/directive_mixin.py
Normal file
330
directive_layer/models/directive_mixin.py
Normal file
@ -0,0 +1,330 @@
|
||||
import functools
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class DirectiveMixin(models.AbstractModel):
|
||||
_name = "directive.mixin"
|
||||
_description = "Directive Mixin"
|
||||
|
||||
directive_origin_id = fields.Many2one(
|
||||
"directive.origin",
|
||||
string="Directive Origin",
|
||||
readonly=True,
|
||||
index=True,
|
||||
ondelete="set null",
|
||||
)
|
||||
|
||||
directive_ids = fields.One2many(
|
||||
"directive.directive",
|
||||
string="Директивы",
|
||||
related="directive_origin_id.directive_ids",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
directive_execution_status = fields.Char(
|
||||
string="Текущий статус исполнения",
|
||||
compute="_compute_directive_execution_status",
|
||||
store=True,
|
||||
)
|
||||
|
||||
directive_template_group_id = fields.Many2one(
|
||||
"directive.template.group",
|
||||
string="Группа шаблонов директив",
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
records._ensure_directive_origin()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
_logger.debug(
|
||||
"[directive] write() called. Model: %s, IDs: %s, Keys in vals: %s",
|
||||
self._name, self.ids, list(vals.keys())
|
||||
)
|
||||
|
||||
policies = self.env['directive.policy'].sudo().search([
|
||||
('origin_model', '=', self._name),
|
||||
('event', '=', 'on_write'),
|
||||
('active', '=', True)
|
||||
])
|
||||
|
||||
relevant_policies = policies.filtered(
|
||||
lambda p: p.trigger_field and p.trigger_field in vals
|
||||
)
|
||||
|
||||
before_map = {}
|
||||
if relevant_policies:
|
||||
watched_fields = set(relevant_policies.mapped('trigger_field'))
|
||||
for rec in self:
|
||||
before_map[rec.id] = {f: rec[f] for f in watched_fields}
|
||||
|
||||
res = super().write(vals)
|
||||
|
||||
self._ensure_directive_origin()
|
||||
|
||||
if relevant_policies:
|
||||
for policy in relevant_policies:
|
||||
field = policy.trigger_field
|
||||
|
||||
records_to_process = self.env[self._name]
|
||||
|
||||
for rec in self:
|
||||
before_val = before_map[rec.id].get(field)
|
||||
after_val = rec[field]
|
||||
|
||||
def _norm(v):
|
||||
return v.id if hasattr(v, 'id') else v
|
||||
|
||||
b_val = _norm(before_val)
|
||||
a_val = _norm(after_val)
|
||||
|
||||
if b_val != a_val and policy._policy_should_fire(rec):
|
||||
records_to_process |= rec
|
||||
|
||||
if records_to_process:
|
||||
_logger.info(
|
||||
"[directive] applying policy '%s' for model=%s, ids=%s",
|
||||
policy.name, self._name, records_to_process.ids
|
||||
)
|
||||
policy.sudo().apply_to_records(records_to_process)
|
||||
|
||||
return res
|
||||
|
||||
def _ensure_directive_origin(self):
|
||||
for rec in self:
|
||||
if not rec.id:
|
||||
continue
|
||||
if rec.directive_origin_id:
|
||||
continue
|
||||
origin = self.env["directive.origin"].search(
|
||||
[("res_model", "=", rec._name), ("res_id", "=", rec.id)],
|
||||
limit=1,
|
||||
)
|
||||
if not origin:
|
||||
origin = self.env["directive.origin"].create(
|
||||
{"res_model": rec._name, "res_id": rec.id}
|
||||
)
|
||||
rec.sudo().write({"directive_origin_id": origin.id})
|
||||
|
||||
@api.depends(
|
||||
"directive_ids.stage_id",
|
||||
"directive_ids.stage_id.code",
|
||||
"directive_ids.deadline_at",
|
||||
"directive_ids.planned_end_at",
|
||||
)
|
||||
def _compute_directive_execution_status(self):
|
||||
for rec in self:
|
||||
directives = rec.directive_ids.filtered(
|
||||
lambda d: d.stage_id.code not in ("done", "cancelled")
|
||||
)
|
||||
|
||||
if not directives:
|
||||
rec.directive_execution_status = ""
|
||||
continue
|
||||
|
||||
stage_priority = {
|
||||
"paused": 1,
|
||||
"in_progress": 2,
|
||||
"planned": 3,
|
||||
"confirmed": 3,
|
||||
"draft": 4,
|
||||
}
|
||||
|
||||
def stage_rank(d):
|
||||
return stage_priority.get(d.stage_id.code, 99)
|
||||
|
||||
current_directive = sorted(directives, key=stage_rank)[0]
|
||||
stage_name = current_directive.stage_id.name
|
||||
|
||||
deadlines = directives.mapped("deadline_at")
|
||||
nearest_deadline = min(deadlines) if deadlines else None
|
||||
|
||||
planned_ends = directives.mapped("planned_end_at")
|
||||
planned_ends = [d for d in planned_ends if d]
|
||||
expected_end = max(planned_ends) if planned_ends else max(deadlines)
|
||||
|
||||
parts = [stage_name]
|
||||
|
||||
if nearest_deadline:
|
||||
parts.append(
|
||||
f"до {fields.Datetime.to_string(nearest_deadline)[:10]}"
|
||||
)
|
||||
|
||||
if expected_end:
|
||||
parts.append(
|
||||
f"ожидается завершение к {fields.Datetime.to_string(expected_end)[:10]}"
|
||||
)
|
||||
|
||||
rec.directive_execution_status = ", ".join(parts)
|
||||
|
||||
def _directive_on_assign(self, payload: dict):
|
||||
return None
|
||||
|
||||
def _directive_on_start(self, payload: dict):
|
||||
return None
|
||||
|
||||
def _directive_on_pause(self, payload: dict):
|
||||
return None
|
||||
|
||||
def _directive_on_done(self, payload: dict):
|
||||
return None
|
||||
|
||||
def _directive_create_from_group(self, template_group, generator_key=None, policy=None):
|
||||
self.ensure_one()
|
||||
self._ensure_directive_origin()
|
||||
|
||||
if not template_group:
|
||||
raise UserError("Template group is required.")
|
||||
|
||||
draft_stage = self.env["directive.stage"].search([("code", "=", "draft")], limit=1)
|
||||
if not draft_stage:
|
||||
raise UserError("Stage with code 'draft' not found.")
|
||||
|
||||
lines = template_group.template_line_ids.sorted(key=lambda l: (l.sequence, l.id))
|
||||
if not lines:
|
||||
raise UserError("Template group has no lines.")
|
||||
|
||||
directives_vals = []
|
||||
for line in lines:
|
||||
tmpl = line.template_id
|
||||
directives_vals.append({
|
||||
"generator_key": generator_key or False,
|
||||
"name": tmpl.name,
|
||||
"deadline_at": fields.Datetime.now(),
|
||||
"planned_hours": tmpl.planned_hours,
|
||||
"load_percent": tmpl.load_percent,
|
||||
"priority": tmpl.priority,
|
||||
"work_type_id": tmpl.work_type_id.id,
|
||||
"executor_role_id": tmpl.executor_role_id.id or False,
|
||||
"requester_role_id": tmpl.requester_role_id.id or False,
|
||||
"stage_id": draft_stage.id,
|
||||
"executor_stage_id": False,
|
||||
"requester_stage_id": False,
|
||||
"origin_id": self.directive_origin_id.id,
|
||||
"template_id": tmpl.id,
|
||||
"policy_id": policy.id if policy else False,
|
||||
})
|
||||
|
||||
return self.env["directive.directive"].sudo().create(directives_vals)
|
||||
|
||||
def _directive_update(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def _directive_cancel(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def _directive_delete(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def action_choose_directive_group(self):
|
||||
self.ensure_one()
|
||||
self._ensure_directive_origin()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Выбор группы директив",
|
||||
"res_model": "directive.choose.group.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": {
|
||||
"active_model": self._name,
|
||||
"active_id": self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_directives(self):
|
||||
self.ensure_one()
|
||||
self._ensure_directive_origin()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Директивы",
|
||||
"res_model": "directive.directive",
|
||||
"view_mode": "kanban,list,form",
|
||||
"domain": [("origin_id", "=", self.directive_origin_id.id)],
|
||||
"context": {
|
||||
"default_origin_id": self.directive_origin_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _directive_generation_spec(self):
|
||||
"""Может быть переопределен в модели-источнике."""
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def _register_hook(self):
|
||||
res = super()._register_hook()
|
||||
_logger.info(
|
||||
"[directive] register_hook model=%s",
|
||||
self._name
|
||||
)
|
||||
self._directive_register_callback_wrappers()
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _directive_register_callback_wrappers(self):
|
||||
cls = self.__class__
|
||||
|
||||
if cls.__dict__.get("_directive_callbacks_wrapped"):
|
||||
return
|
||||
cls._directive_callbacks_wrapped = True
|
||||
|
||||
self.env.cr.execute("SELECT count(*) FROM information_schema.tables WHERE table_name = 'directive_policy'")
|
||||
if not self.env.cr.fetchone()[0]:
|
||||
return
|
||||
|
||||
policies = self.env['directive.policy'].sudo().search([
|
||||
('origin_model', '=', self._name),
|
||||
('event', '=', 'on_callback'),
|
||||
('active', '=', True)
|
||||
])
|
||||
|
||||
if not policies:
|
||||
return
|
||||
|
||||
for policy in policies:
|
||||
callback_name = policy.trigger_field
|
||||
if not callback_name:
|
||||
continue
|
||||
|
||||
orig = getattr(cls, callback_name, None)
|
||||
|
||||
if not orig or not callable(orig):
|
||||
_logger.warning(
|
||||
"[directive] Не найден метод '%s' в модели %s (из политики %s)",
|
||||
callback_name, self._name, policy.name
|
||||
)
|
||||
continue
|
||||
|
||||
if getattr(orig, "_directive_wrapped", False):
|
||||
continue
|
||||
|
||||
_logger.info(
|
||||
"[directive] Оборачиваем метод %s.%s для политики '%s'",
|
||||
self._name, callback_name, policy.name
|
||||
)
|
||||
|
||||
policy_id = policy.id
|
||||
|
||||
@functools.wraps(orig)
|
||||
def _wrapped(self_recordset, *args, __orig=orig, __policy_id=policy_id, **kwargs):
|
||||
if self_recordset.env.context.get("directive_skip_generation"):
|
||||
return __orig(self_recordset, *args, **kwargs)
|
||||
|
||||
result = __orig(self_recordset, *args, **kwargs)
|
||||
|
||||
if result is not False:
|
||||
active_policy = self_recordset.env['directive.policy'].sudo().browse(__policy_id)
|
||||
if active_policy.exists() and active_policy.active:
|
||||
active_policy.sudo().apply_to_records(self_recordset)
|
||||
|
||||
return result
|
||||
|
||||
_wrapped._directive_wrapped = True
|
||||
setattr(cls, callback_name, _wrapped)
|
||||
75
directive_layer/models/directive_origin.py
Normal file
75
directive_layer/models/directive_origin.py
Normal file
@ -0,0 +1,75 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class DirectiveOrigin(models.Model):
|
||||
_name = "directive.origin"
|
||||
_description = "Directive Origin"
|
||||
_rec_name = "display_name"
|
||||
_order = "id desc"
|
||||
|
||||
res_model = fields.Char(string="Техническое имя модели", required=True, index=True)
|
||||
res_id = fields.Integer(string="ID записи", required=True, index=True)
|
||||
|
||||
res_ref = fields.Reference(
|
||||
selection="_get_reference_models",
|
||||
string="Ссылка на запись",
|
||||
compute="_compute_res_ref",
|
||||
inverse="_inverse_res_ref",
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
directive_ids = fields.One2many(
|
||||
"directive.directive",
|
||||
"origin_id",
|
||||
string="Директивы",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
display_name = fields.Char(
|
||||
string="Источник (display_name)",
|
||||
compute="_compute_display_name",
|
||||
store=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
("directive_origin_uniq", "unique(res_model, res_id)", "Origin must be unique per (model, id)."),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_reference_models(self):
|
||||
models = self.env["ir.model"].sudo().search([])
|
||||
return [(m.model, m.name) for m in models if m.model]
|
||||
|
||||
@api.depends("res_model", "res_id")
|
||||
def _compute_res_ref(self):
|
||||
for rec in self:
|
||||
if rec.res_model and rec.res_id:
|
||||
rec.res_ref = f"{rec.res_model},{rec.res_id}"
|
||||
else:
|
||||
rec.res_ref = False
|
||||
|
||||
def _inverse_res_ref(self):
|
||||
for rec in self:
|
||||
if not rec.res_ref:
|
||||
rec.res_model = False
|
||||
rec.res_id = 0
|
||||
continue
|
||||
try:
|
||||
model, rid = rec.res_ref.split(",")
|
||||
rec.res_model = model
|
||||
rec.res_id = int(rid)
|
||||
except Exception:
|
||||
rec.res_model = False
|
||||
rec.res_id = 0
|
||||
|
||||
@api.depends("res_ref")
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
if rec.res_ref:
|
||||
try:
|
||||
rec.display_name = f"{rec.res_model},{rec.res_id}"
|
||||
except Exception:
|
||||
rec.display_name = f"{rec.res_model},{rec.res_id}"
|
||||
else:
|
||||
rec.display_name = f"{rec.res_model},{rec.res_id}"
|
||||
123
directive_layer/models/directive_policy.py
Normal file
123
directive_layer/models/directive_policy.py
Normal file
@ -0,0 +1,123 @@
|
||||
from odoo import api, fields, models
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class DirectivePolicy(models.Model):
|
||||
_name = "directive.policy"
|
||||
_description = "Directive Policy"
|
||||
_order = "sequence, id"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
origin_model = fields.Char(
|
||||
string="Origin model",
|
||||
required=True,
|
||||
index=True,
|
||||
help="Техническое имя модели источника, например 'project.task'",
|
||||
)
|
||||
|
||||
event = fields.Selection(
|
||||
[
|
||||
("manual", "Manual"),
|
||||
("on_create", "On Create"),
|
||||
("on_write", "On Write"),
|
||||
("on_stage", "On Stage Change"),
|
||||
("on_cron", "On Cron"),
|
||||
("on_callback", "При вызове метода (on_callback)"),
|
||||
],
|
||||
required=True,
|
||||
index=True,
|
||||
default="manual",
|
||||
)
|
||||
|
||||
mode = fields.Selection(
|
||||
[
|
||||
("create_once", "Create once"),
|
||||
("create_each_time", "Create each time"),
|
||||
],
|
||||
required=True,
|
||||
default="create_once",
|
||||
)
|
||||
|
||||
use_record_template_group = fields.Boolean(
|
||||
default=True,
|
||||
help="Если включено, группа берётся из поля записи-источника directive_template_group_id.",
|
||||
)
|
||||
|
||||
template_group_id = fields.Many2one(
|
||||
"directive.template.group",
|
||||
string="Fixed template group",
|
||||
help="Если указано и use_record_template_group=False — будет использоваться эта группа.",
|
||||
)
|
||||
|
||||
trigger_field = fields.Char(
|
||||
help="Имя поля источника, например 'stage_id'."
|
||||
)
|
||||
trigger_value_int = fields.Integer(
|
||||
help="Для Many2one/Integer: ожидаемое значение (например ID стадии)."
|
||||
)
|
||||
|
||||
def _policy_should_fire(self, record, payload: dict | None = None) -> bool:
|
||||
self.ensure_one()
|
||||
|
||||
# Для on_callback факт вызова метода - это уже триггер.
|
||||
if self.event == 'on_callback':
|
||||
return True
|
||||
|
||||
if not self.trigger_field:
|
||||
return True
|
||||
|
||||
value = getattr(record, self.trigger_field, False)
|
||||
|
||||
if hasattr(value, "id"):
|
||||
match = bool(value and value.id == self.trigger_value_int)
|
||||
if not match:
|
||||
_logger.debug("[directive.policy] ID mismatch: expected %s, got %s", self.trigger_value_int, value.id)
|
||||
return match
|
||||
|
||||
try:
|
||||
match = (value == self.trigger_value_int)
|
||||
if not match:
|
||||
_logger.debug("[directive.policy] Value mismatch: expected %s, got %s", self.trigger_value_int, value)
|
||||
return match
|
||||
except Exception as e:
|
||||
_logger.error("[directive.policy] Error comparing values: %s", e)
|
||||
return False
|
||||
|
||||
def _resolve_template_group(self, record):
|
||||
self.ensure_one()
|
||||
if self.use_record_template_group:
|
||||
return record.directive_template_group_id
|
||||
return self.template_group_id
|
||||
|
||||
def apply_to_records(self, records, payload: dict | None = None):
|
||||
self.ensure_one()
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
created_total = 0
|
||||
for rec in records:
|
||||
if rec._name != self.origin_model:
|
||||
continue
|
||||
|
||||
if not self._policy_should_fire(rec, payload=payload):
|
||||
continue
|
||||
|
||||
group = self._resolve_template_group(rec)
|
||||
if not group:
|
||||
continue
|
||||
|
||||
if self.mode == "create_once":
|
||||
existing = rec.env["directive.directive"].search_count([
|
||||
("origin_id", "=", rec.directive_origin_id.id),
|
||||
("policy_id", "=", self.id),
|
||||
])
|
||||
if existing:
|
||||
continue
|
||||
|
||||
rec._directive_create_from_group(group, policy=self)
|
||||
created_total += 1
|
||||
|
||||
return created_total
|
||||
16
directive_layer/models/directive_stage.py
Normal file
16
directive_layer/models/directive_stage.py
Normal file
@ -0,0 +1,16 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DirectiveStage(models.Model):
|
||||
_name = "directive.stage"
|
||||
_description = "Directive Stage"
|
||||
_order = "id asc"
|
||||
|
||||
name = fields.Char(string="Название статуса", required=True)
|
||||
code = fields.Char(string="Технический код", required=True, index=True)
|
||||
|
||||
is_hidden = fields.Boolean(string="Скрыт в канбане", default=False)
|
||||
|
||||
_sql_constraints = [
|
||||
("directive_stage_code_uniq", "unique(code)", "Stage code must be unique."),
|
||||
]
|
||||
69
directive_layer/models/directive_template.py
Normal file
69
directive_layer/models/directive_template.py
Normal file
@ -0,0 +1,69 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DirectiveTemplate(models.Model):
|
||||
_name = "directive.template"
|
||||
_description = "Directive Template"
|
||||
_order = "name asc, id desc"
|
||||
|
||||
PRIORITY = [
|
||||
("0", "Low"),
|
||||
("1", "Normal"),
|
||||
("2", "High"),
|
||||
]
|
||||
|
||||
name = fields.Char(string="Название шаблона директивы", required=True)
|
||||
code = fields.Char(string="Технический код", required=True, index=True)
|
||||
active = fields.Boolean(string="Активен", default=True)
|
||||
|
||||
sequence = fields.Integer(string="Порядок", default=10)
|
||||
|
||||
work_type_id = fields.Many2one(
|
||||
"directive.work.type",
|
||||
string="Тип работ",
|
||||
required=True,
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
planned_hours = fields.Float(string="Трудоёмкость", required=True)
|
||||
load_percent = fields.Integer(string="Процент загрузки", default=10)
|
||||
|
||||
priority = fields.Selection(
|
||||
selection=PRIORITY,
|
||||
string="Приоритет",
|
||||
default="0",
|
||||
required=True,
|
||||
)
|
||||
|
||||
executor_role_id = fields.Many2one(
|
||||
"res.users.role",
|
||||
string="Роль исполнителя (по умолчанию)",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
requester_role_id = fields.Many2one(
|
||||
"res.users.role",
|
||||
string="Роль заявителя (по умолчанию)",
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
default_stage_id = fields.Many2one(
|
||||
"directive.stage",
|
||||
string="Стартовый статус",
|
||||
default=lambda self: self.env["directive.stage"].search([("code", "=", "draft")], limit=1),
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
|
||||
handler_key = fields.Char(
|
||||
string="Handler key",
|
||||
help="Например: project.task.collect_ft (метод модели источника)",
|
||||
)
|
||||
|
||||
description = fields.Text(string="Описание/инструкции")
|
||||
|
||||
_sql_constraints = [
|
||||
("directive_template_code_uniq", "unique(code)", "Template code must be unique."),
|
||||
]
|
||||
50
directive_layer/models/directive_template_group.py
Normal file
50
directive_layer/models/directive_template_group.py
Normal file
@ -0,0 +1,50 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DirectiveTemplateGroup(models.Model):
|
||||
_name = "directive.template.group"
|
||||
_description = "Directive Template Group"
|
||||
_order = "name asc, id desc"
|
||||
|
||||
name = fields.Char(string="Название группы шаблонов", required=True)
|
||||
code = fields.Char(string="Технический код", index=True)
|
||||
active = fields.Boolean(string="Активен", default=True)
|
||||
|
||||
applicable_model = fields.Char(
|
||||
string="Для какой модели доступен",
|
||||
help='Например: "project.task". Если пусто — доступен для всех.',
|
||||
index=True,
|
||||
)
|
||||
|
||||
description = fields.Text(string="Описание")
|
||||
|
||||
template_line_ids = fields.One2many(
|
||||
"directive.template.group.line",
|
||||
"group_id",
|
||||
string="Шаги (шаблоны директив)",
|
||||
copy=True,
|
||||
)
|
||||
|
||||
|
||||
class DirectiveTemplateGroupLine(models.Model):
|
||||
_name = "directive.template.group.line"
|
||||
_description = "Directive Template Group Line"
|
||||
_order = "sequence asc, id asc"
|
||||
|
||||
group_id = fields.Many2one(
|
||||
"directive.template.group",
|
||||
string="Группа",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
|
||||
sequence = fields.Integer(string="Порядок", default=10)
|
||||
|
||||
template_id = fields.Many2one(
|
||||
"directive.template",
|
||||
string="Шаблон директивы",
|
||||
required=True,
|
||||
ondelete="restrict",
|
||||
index=True,
|
||||
)
|
||||
17
directive_layer/models/directive_work_type.py
Normal file
17
directive_layer/models/directive_work_type.py
Normal file
@ -0,0 +1,17 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DirectiveWorkType(models.Model):
|
||||
_name = "directive.work.type"
|
||||
_description = "Directive Work Type"
|
||||
_order = "name asc, id desc"
|
||||
|
||||
name = fields.Char(string="Название вида работ", required=True)
|
||||
code = fields.Char(string="Технический код", index=True)
|
||||
|
||||
active = fields.Boolean(string="Активен", default=True)
|
||||
description = fields.Text(string="Описание")
|
||||
|
||||
_sql_constraints = [
|
||||
("directive_work_type_code_uniq", "unique(code)", "Work type code must be unique."),
|
||||
]
|
||||
27
directive_layer/models/res_users.py
Normal file
27
directive_layer/models/res_users.py
Normal file
@ -0,0 +1,27 @@
|
||||
from odoo import fields, models, api
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
for user in users:
|
||||
self._check_and_set_executor_home(user)
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'group_ids' in vals:
|
||||
for user in self:
|
||||
self._check_and_set_executor_home(user)
|
||||
return res
|
||||
|
||||
def _check_and_set_executor_home(self, user):
|
||||
executor_group = self.env.ref('directive_layer.role_directive_executor_res_groups', raise_if_not_found=False)
|
||||
dashboard_action = self.env.ref('directive_layer.action_executor_dashboard_client', raise_if_not_found=False)
|
||||
|
||||
if executor_group and dashboard_action:
|
||||
if user.has_group('directive_layer.role_directive_executor_res_groups'):
|
||||
if user.action_id != dashboard_action:
|
||||
user.sudo().write({'action_id': dashboard_action.id})
|
||||
16
directive_layer/security/directives_rules.xml
Normal file
16
directive_layer/security/directives_rules.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<odoo>
|
||||
<record id="rule_directive_base_access" model="ir.rule">
|
||||
<field name="name">Directive: base access</field>
|
||||
<field name="model_id" ref="model_directive_directive"/>
|
||||
<field name="global" eval="True"/>
|
||||
<field name="domain_force"><![CDATA[
|
||||
user.has_group('directive_layer.role_directive_dispatcher_res_groups') and [(1,'=',1)] or [
|
||||
'|', '|', '|',
|
||||
('executor_user_id', '=', user.id),
|
||||
('create_uid', '=', user.id),
|
||||
('executor_role_id', 'in', user.role_ids.ids),
|
||||
('requester_role_id', 'in', user.role_ids.ids)
|
||||
]
|
||||
]]></field>
|
||||
</record>
|
||||
</odoo>
|
||||
11
directive_layer/security/ir.model.access.csv
Normal file
11
directive_layer/security/ir.model.access.csv
Normal file
@ -0,0 +1,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_directive,access_directive,model_directive_directive,base.group_user,1,1,1,1
|
||||
access_directive_stage,access_directive_stage,model_directive_stage,base.group_user,1,1,1,1
|
||||
access_directive_work_type,access_directive_work_type,model_directive_work_type,base.group_user,1,1,1,1
|
||||
access_directive_origin,access_directive_origin,model_directive_origin,base.group_user,1,1,1,1
|
||||
access_directive_template,access_directive_template,model_directive_template,base.group_user,1,1,1,1
|
||||
access_directive_template_group,access_directive_template_group,model_directive_template_group,base.group_user,1,1,1,1
|
||||
access_directive_template_group_line,access_directive_template_group_line,model_directive_template_group_line,base.group_user,1,1,1,1
|
||||
access_directive_choose_group_wizard,access_directive_choose_group_wizard,model_directive_choose_group_wizard,base.group_user,1,0,1,0
|
||||
access_directive_delete_wizard,access_directive_delete_wizard,model_directive_delete_wizard,base.group_user,1,0,1,0
|
||||
access_directive_policy,access_directive_policy,model_directive_policy,base.group_user,1,1,1,1
|
||||
|
25
directive_layer/security/roles.xml
Normal file
25
directive_layer/security/roles.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<odoo>
|
||||
<record id="role_directive_executor" model="res.users.role">
|
||||
<field name="name">Директивы / Исполнитель</field>
|
||||
</record>
|
||||
|
||||
<record id="role_directive_requester" model="res.users.role">
|
||||
<field name="name">Директивы / Заявитель</field>
|
||||
</record>
|
||||
|
||||
<record id="role_directive_dispatcher" model="res.users.role">
|
||||
<field name="name">Директивы / Диспетчер</field>
|
||||
</record>
|
||||
|
||||
<record id="role_directive_executor_res_groups" model="res.groups">
|
||||
<field name="name">Директивы / Исполнитель</field>
|
||||
</record>
|
||||
|
||||
<record id="role_directive_requester_res_groups" model="res.groups">
|
||||
<field name="name">Директивы / Заявитель</field>
|
||||
</record>
|
||||
|
||||
<record id="role_directive_dispatcher_res_groups" model="res.groups">
|
||||
<field name="name">Директивы / Диспетчер</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@ -0,0 +1,90 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { session } from "@web/session";
|
||||
|
||||
export class ExecutorBoard extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.userId = session.user_id || session.uid || (session.storeData?.Store?.settings?.user_id?.id);
|
||||
|
||||
this.rawData = {
|
||||
stages: [],
|
||||
directives: []
|
||||
};
|
||||
|
||||
this.state = useState({
|
||||
columns: [],
|
||||
loading: true,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
if (!this.userId) {
|
||||
const partner = session.storeData?.["res.partner"]?.find(p => p.userId);
|
||||
this.userId = partner ? partner.userId : false;
|
||||
}
|
||||
await this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
if (!this.userId) return;
|
||||
|
||||
this.rawData.stages = await this.orm.searchRead(
|
||||
"directive.stage",
|
||||
[],
|
||||
["id", "name", "code"],
|
||||
);
|
||||
|
||||
// 2. Грузим директивы
|
||||
this.rawData.directives = await this.orm.searchRead(
|
||||
"directive.directive",
|
||||
[["executor_user_id", "=", this.userId]],
|
||||
[
|
||||
"id", "name", "executor_stage_id",
|
||||
"deadline_display", "time_left_display",
|
||||
"planned_hours", "priority", "priority_stars", "is_overdue"
|
||||
]
|
||||
);
|
||||
|
||||
this.processData();
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
processData() {
|
||||
this.state.columns = this.rawData.stages.map(stage => {
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
code: stage.code,
|
||||
directives: this.rawData.directives.filter(d => d.executor_stage_id && d.executor_stage_id[0] === stage.id)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async quickAction(ev, directiveId, actionType) {
|
||||
ev.stopPropagation();
|
||||
|
||||
let targetCode = '';
|
||||
if (actionType === 'start') targetCode = 'in_progress';
|
||||
if (actionType === 'pause') targetCode = 'paused';
|
||||
if (actionType === 'finish') targetCode = 'done';
|
||||
|
||||
if (targetCode) {
|
||||
await this.orm.call("directive.directive", "action_quick_change_stage", [directiveId, targetCode]);
|
||||
await this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async openDirective(directiveId) {
|
||||
}
|
||||
|
||||
async logout() {
|
||||
window.location.href = "/web/session/logout";
|
||||
}
|
||||
}
|
||||
|
||||
ExecutorBoard.template = "directive_layer.ExecutorBoard";
|
||||
registry.category("actions").add("directive_layer.executor_dashboard_action", ExecutorBoard);
|
||||
@ -0,0 +1,222 @@
|
||||
.o_executor_dashboard {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f4f5f7; // Общий фон
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
|
||||
// --- Header ---
|
||||
.executor_header {
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
|
||||
.brand {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #172b4d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header_actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Board Container ---
|
||||
.executor_board_container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 20px;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
// Mobile: Scroll Snap Logic
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// --- Column ---
|
||||
.kanban_column {
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
background: #ebecf0; // Цвет колонки (светлее)
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
scroll-snap-align: center; // Центровка на мобильных
|
||||
|
||||
.column_header {
|
||||
padding: 14px 16px;
|
||||
font-weight: 600;
|
||||
color: #5e6c84;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid transparent; // Для цветной полоски
|
||||
}
|
||||
|
||||
// Цветные полоски для статусов
|
||||
&:nth-child(1) .column_header { border-bottom-color: #6c757d; } // Черновик
|
||||
&:nth-child(2) .column_header { border-bottom-color: #0dcaf0; } // В работе
|
||||
&:nth-child(3) .column_header { border-bottom-color: #ffc107; } // Ожидание
|
||||
&:nth-child(4) .column_header { border-bottom-color: #198754; } // Готово
|
||||
|
||||
.column_content {
|
||||
padding: 12px 8px 8px 8px; // ВЕРХНИЙ ОТСТУП (1)
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px; // Расстояние между карточками
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Card ---
|
||||
.directive_card {
|
||||
background: #ffffff; // Белый фон карточки
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 2px rgba(9, 30, 66, 0.25); // Тень
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafbfc;
|
||||
}
|
||||
|
||||
&.card_overdue {
|
||||
border-left: 4px solid #dc3545;
|
||||
background-color: #fff8f8;
|
||||
}
|
||||
|
||||
.card_top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center; // Выравнивание
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #97a0af;
|
||||
|
||||
.card_stars {
|
||||
color: #f0ad4e; // Цвет звезд (оранжевый)
|
||||
font-size: 1rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.card_title {
|
||||
font-weight: 600;
|
||||
color: #172b4d;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card_meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #5e6c84;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.meta_item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&.deadline.text-danger {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card_actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #ebecf0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.btn-run { background: #e6f7ff; color: #007bff; }
|
||||
&.btn-run:hover { background: #007bff; color: white; }
|
||||
|
||||
&.btn-pause { background: #fff3cd; color: #856404; }
|
||||
&.btn-pause:hover { background: #ffc107; color: white; }
|
||||
|
||||
&.btn-done { background: #d4edda; color: #155724; }
|
||||
&.btn-done:hover { background: #28a745; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card_actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.executor_header {
|
||||
padding: 0 10px;
|
||||
.brand span { display: none; }
|
||||
}
|
||||
|
||||
.executor_board_container {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
|
||||
.kanban_column {
|
||||
min-width: 85vw;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.directive_card {
|
||||
padding: 16px;
|
||||
|
||||
.card_title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card_actions {
|
||||
opacity: 1 !important;
|
||||
justify-content: space-between;
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="directive_layer.ExecutorBoard">
|
||||
<div class="o_executor_dashboard">
|
||||
<div class="executor_header">
|
||||
<div class="brand">
|
||||
<i class="fa fa-cube me-2 text-primary"/>
|
||||
<span>Мои Директивы</span>
|
||||
</div>
|
||||
<div class="header_actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="logout">
|
||||
<i class="fa fa-sign-out"/> Выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="executor_board_container">
|
||||
<t t-if="state.loading">
|
||||
<div class="d-flex justify-content-center w-100 mt-5">
|
||||
<div class="spinner-border text-primary"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-foreach="state.columns" t-as="column" t-key="column.id">
|
||||
<div class="kanban_column">
|
||||
<div class="column_header">
|
||||
<span><t t-esc="column.name"/></span>
|
||||
<span class="badge rounded-pill bg-white text-dark shadow-sm">
|
||||
<t t-esc="column.directives.length"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column_content">
|
||||
<t t-foreach="column.directives" t-as="card" t-key="card.id">
|
||||
<div class="directive_card"
|
||||
t-att-class="card.is_overdue ? 'card_overdue' : ''"
|
||||
t-on-click="() => this.openDirective(card.id)">
|
||||
|
||||
<div class="card_top">
|
||||
<span class="text-muted">#<t t-esc="card.id"/></span>
|
||||
<span class="card_stars" title="Приоритет">
|
||||
<t t-esc="card.priority_stars"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card_title">
|
||||
<t t-esc="card.name"/>
|
||||
</div>
|
||||
|
||||
<div class="card_meta">
|
||||
<div class="meta_item deadline" t-att-class="card.is_overdue ? 'text-danger' : ''">
|
||||
<i class="fa fa-clock-o"/>
|
||||
<t t-esc="card.deadline_display"/>
|
||||
</div>
|
||||
<div class="meta_item" t-if="card.time_left_display">
|
||||
<i class="fa fa-hourglass-start"/>
|
||||
<t t-esc="card.time_left_display"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card_actions" t-on-click.stop="">
|
||||
<t t-if="column.code == 'planned' or column.code == 'paused'">
|
||||
<button class="btn-action btn-run"
|
||||
t-on-click="(ev) => this.quickAction(ev, card.id, 'start')">
|
||||
<i class="fa fa-play me-1"/> В работу
|
||||
</button>
|
||||
</t>
|
||||
|
||||
<t t-if="column.code == 'in_progress'">
|
||||
<button class="btn-action btn-pause"
|
||||
t-on-click="(ev) => this.quickAction(ev, card.id, 'pause')">
|
||||
<i class="fa fa-pause me-1"/> Пауза
|
||||
</button>
|
||||
<button class="btn-action btn-done"
|
||||
t-on-click="(ev) => this.quickAction(ev, card.id, 'finish')">
|
||||
<i class="fa fa-check me-1"/> Готово
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
93
directive_layer/static/src/scss/directive_kanban.scss
Normal file
93
directive_layer/static/src/scss/directive_kanban.scss
Normal file
@ -0,0 +1,93 @@
|
||||
.o_kanban_view .directive_card {
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px 16px 16px;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,.08);
|
||||
transition: transform .15s ease, box-shadow .15s ease;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 14px 28px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_deadline_label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_deadline_value {
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_priority .fa-star {
|
||||
color: #ffc107 !important;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_meta {
|
||||
opacity: 0.9;
|
||||
font-size: 14px; /* крупнее */
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.o_kanban_view .meta_label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.o_kanban_view .meta_value {
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.o_kanban_view .title_row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.o_kanban_view .deadline_row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_tl {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #28a745;
|
||||
box-shadow: 0 0 0 4px rgba(40,167,69,.25);
|
||||
}
|
||||
|
||||
.o_kanban_large_cards .o_kanban_record {
|
||||
width: 320px !important;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_tl {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.o_kanban_view .directive_card:hover .directive_tl {
|
||||
box-shadow: 0 0 0 5px rgba(40,167,69,.35);
|
||||
}
|
||||
|
||||
.o_kanban_view .o_kanban_record.directive_card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
1
directive_layer/tests/__init__.py
Normal file
1
directive_layer/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import test_directive_layer
|
||||
107
directive_layer/tests/test_directive_layer.py
Normal file
107
directive_layer/tests/test_directive_layer.py
Normal file
@ -0,0 +1,107 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
@tagged('standard', 'at_install', 'directive_logic')
|
||||
class TestDirectivePolicy(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Метод запускается перед каждым тестом.
|
||||
Гарантирует изоляцию данных: каждый тест работает с новой задачей и новой политикой.
|
||||
"""
|
||||
super(TestDirectivePolicy, self).setUp()
|
||||
|
||||
self.work_type = self.env['directive.work.type'].create({
|
||||
'name': 'Development',
|
||||
'code': 'dev'
|
||||
})
|
||||
|
||||
self.stage_draft = self.env['directive.stage'].search([('code', '=', 'draft')], limit=1)
|
||||
if not self.stage_draft:
|
||||
self.stage_draft = self.env['directive.stage'].create({'name': 'Draft', 'code': 'draft'})
|
||||
|
||||
self.template = self.env['directive.template'].create({
|
||||
'name': 'Шаблон авто-задачи',
|
||||
'code': 'T001',
|
||||
'work_type_id': self.work_type.id,
|
||||
'planned_hours': 5.0,
|
||||
})
|
||||
|
||||
self.template_group = self.env['directive.template.group'].create({
|
||||
'name': 'Test Group',
|
||||
'template_line_ids': [(0, 0, {'template_id': self.template.id})]
|
||||
})
|
||||
|
||||
self.policy_callback = self.env['directive.policy'].create({
|
||||
'name': 'On Callback Policy',
|
||||
'origin_model': 'project.task',
|
||||
'event': 'on_callback',
|
||||
'trigger_field': 'action_demo_directive_callback',
|
||||
'mode': 'create_once',
|
||||
'use_record_template_group': False,
|
||||
'template_group_id': self.template_group.id,
|
||||
})
|
||||
|
||||
self.policy_write = self.env['directive.policy'].create({
|
||||
'name': 'On Write Policy',
|
||||
'origin_model': 'project.task',
|
||||
'event': 'on_write',
|
||||
'trigger_field': 'color',
|
||||
'trigger_value_int': 5,
|
||||
'mode': 'create_once',
|
||||
'use_record_template_group': False,
|
||||
'template_group_id': self.template_group.id,
|
||||
})
|
||||
|
||||
self.task = self.env['project.task'].create({
|
||||
'name': 'Source Task',
|
||||
'color': 0,
|
||||
})
|
||||
|
||||
def test_01_policy_on_write(self):
|
||||
"""Тест генерации директивы при изменении поля (on_write)"""
|
||||
self.task.write({'color': 5})
|
||||
|
||||
directives = self.env['directive.directive'].search([
|
||||
('origin_id.res_model', '=', 'project.task'),
|
||||
('origin_id.res_id', '=', self.task.id),
|
||||
('policy_id', '=', self.policy_write.id)
|
||||
])
|
||||
|
||||
self.assertEqual(len(directives), 1, "Директива не создалась через on_write!")
|
||||
self.assertEqual(directives.name, 'Шаблон авто-задачи', "Имя перенеслось неверно")
|
||||
self.assertEqual(directives.planned_hours, 5.0, "Трудоемкость перенеслась неверно")
|
||||
|
||||
# Проверка create_once
|
||||
self.task.write({'color': 0})
|
||||
self.task.write({'color': 5})
|
||||
|
||||
directives_count = self.env['directive.directive'].search_count([
|
||||
('origin_id.res_model', '=', 'project.task'),
|
||||
('origin_id.res_id', '=', self.task.id),
|
||||
('policy_id', '=', self.policy_write.id)
|
||||
])
|
||||
|
||||
self.assertEqual(directives_count, 1, "Сломался create_once! Создался дубль директивы.")
|
||||
|
||||
def test_02_policy_on_callback(self):
|
||||
"""Тест генерации директивы при вызове метода (on_callback)"""
|
||||
self.task.action_demo_directive_callback()
|
||||
|
||||
directives = self.env['directive.directive'].search([
|
||||
('origin_id.res_model', '=', 'project.task'),
|
||||
('origin_id.res_id', '=', self.task.id),
|
||||
('policy_id', '=', self.policy_callback.id)
|
||||
])
|
||||
|
||||
self.assertEqual(len(directives), 1, "Директива не создалась через on_callback!")
|
||||
|
||||
# Проверка create_once
|
||||
self.task.action_demo_directive_callback()
|
||||
|
||||
directives_count = self.env['directive.directive'].search_count([
|
||||
('origin_id.res_model', '=', 'project.task'),
|
||||
('origin_id.res_id', '=', self.task.id),
|
||||
('policy_id', '=', self.policy_callback.id)
|
||||
])
|
||||
|
||||
self.assertEqual(directives_count, 1, "Сломался create_once для методов! Создался дубль.")
|
||||
14
directive_layer/views/cron.xml
Normal file
14
directive_layer/views/cron.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_generate_simulation_data" model="ir.cron">
|
||||
<field name="name">Demo: Генерация данных для Симуляции (Mesa)</field>
|
||||
<field name="model_id" ref="model_directive_directive"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_generate_simulation_data()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
7
directive_layer/views/directive_actions.xml
Normal file
7
directive_layer/views/directive_actions.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<odoo>
|
||||
<record id="action_executor_dashboard_client" model="ir.actions.client">
|
||||
<field name="name">Рабочий стол Исполнителя</field>
|
||||
<field name="tag">directive_layer.executor_dashboard_action</field>
|
||||
<field name="target">fullscreen</field>
|
||||
</record>
|
||||
</odoo>
|
||||
127
directive_layer/views/directive_policy_views.xml
Normal file
127
directive_layer/views/directive_policy_views.xml
Normal file
@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_directive_policy_form" model="ir.ui.view">
|
||||
<field name="name">directive.policy.form</field>
|
||||
<field name="model">directive.policy</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Политика генерации директив">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="В архиве" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Название политики"/>
|
||||
<h1>
|
||||
<field name="name" placeholder="Напр: Автозапуск при подтверждении"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Основные настройки">
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="origin_model" placeholder="Напр: project.task"/>
|
||||
<field name="event"/>
|
||||
<field name="mode"/>
|
||||
</group>
|
||||
|
||||
<group string="Настройки триггера" invisible="event not in ['on_write', 'on_callback']">
|
||||
|
||||
<label for="trigger_field" string="Поле триггера" invisible="event != 'on_write'"/>
|
||||
<label for="trigger_field" string="Название метода" invisible="event != 'on_callback'"/>
|
||||
|
||||
<field name="trigger_field"
|
||||
nolabel="1"
|
||||
required="event in ['on_write', 'on_callback']"
|
||||
placeholder="Напр: stage_id или action_confirm"/>
|
||||
|
||||
<field name="trigger_value_int"
|
||||
invisible="event != 'on_write'"
|
||||
placeholder="Ожидаемое ID значения"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Шаблоны">
|
||||
<field name="use_record_template_group"/>
|
||||
<field name="template_group_id"
|
||||
invisible="use_record_template_group"
|
||||
required="not use_record_template_group"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_policy_tree" model="ir.ui.view">
|
||||
<field name="name">directive.policy.list</field>
|
||||
<field name="model">directive.policy</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Политики директив">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="origin_model"/>
|
||||
<field name="event"/>
|
||||
<field name="trigger_field" string="Триггер (Поле/Метод)"/>
|
||||
<field name="mode" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_template_group_form" model="ir.ui.view">
|
||||
<field name="name">directive.template.group.form</field>
|
||||
<field name="model">directive.template.group</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Название группы шаблонов"/>
|
||||
<h1><field name="name" placeholder="Напр: Стандартная разработка модуля"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="applicable_model"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Состав группы (директивы)" name="lines">
|
||||
<field name="template_line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="template_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_policy_search" model="ir.ui.view">
|
||||
<field name="name">directive.policy.search</field>
|
||||
<field name="model">directive.policy</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Поиск политик">
|
||||
<field name="name"/>
|
||||
<field name="origin_model"/>
|
||||
<filter string="Активные" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Архивные" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_policy" model="ir.actions.act_window">
|
||||
<field name="name">Политики директив</field>
|
||||
<field name="res_model">directive.policy</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_directive_policy_search"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_directive_policy_action"
|
||||
name="Политики"
|
||||
parent="directive_layer.menu_directive_root"
|
||||
action="action_directive_policy"
|
||||
sequence="20"/>
|
||||
</data>
|
||||
</odoo>
|
||||
247
directive_layer/views/directive_views.xml
Normal file
247
directive_layer/views/directive_views.xml
Normal file
@ -0,0 +1,247 @@
|
||||
<odoo>
|
||||
<record id="view_directive_kanban" model="ir.ui.view">
|
||||
<field name="name">directive.kanban</field>
|
||||
<field name="model">directive.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="stage_id" class="o_kanban_small_column o_kanban_large_cards">
|
||||
<field name="name"/>
|
||||
<field name="deadline_display"/>
|
||||
<field name="time_left_display"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="priority"/>
|
||||
<field name="is_overdue"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="directive_card w-100"
|
||||
t-att-style="record.is_overdue.raw_value ? 'background-color: #f4e1e1;' : 'background-color: #d9efff;'">
|
||||
|
||||
<div class="directive_row title_row d-flex justify-content-between align-items-center">
|
||||
<div class="directive_title fw-bold">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
<div class="directive_priority">
|
||||
<field name="priority" widget="priority"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="directive_row deadline_row mt-2">
|
||||
<div class="directive_deadline_label text-muted">
|
||||
<i class="fa fa-calendar-o me-2"/> Дедлайн:
|
||||
</div>
|
||||
<div class="directive_deadline_value">
|
||||
<t t-esc="record.deadline_display.value"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="directive_meta mt-3 border-top pt-2">
|
||||
<div class="directive_row d-flex justify-content-between">
|
||||
<div class="meta_label text-muted">Осталось:</div>
|
||||
<div class="meta_value fw-bold">
|
||||
<t t-esc="record.time_left_display.value"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="directive_row d-flex justify-content-between">
|
||||
<div class="meta_label text-muted">Трудоёмкость:</div>
|
||||
<div class="meta_value">
|
||||
<field name="planned_hours"/> ч
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_list" model="ir.ui.view">
|
||||
<field name="name">directive.list</field>
|
||||
<field name="model">directive.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="deadline_at"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="load_percent"/>
|
||||
<field name="priority"/>
|
||||
<field name="work_type_id"/>
|
||||
<field name="executor_user_id"/>
|
||||
<field name="stage_id" groups="directive_layer.role_directive_dispatcher_res_groups"/>
|
||||
<field name="executor_stage_id" groups="directive_layer.role_directive_executor_res_groups,directive_layer.role_directive_dispatcher_res_groups"/>
|
||||
<field name="requester_stage_id" groups="directive_layer.role_directive_requester_res_groups,directive_layer.role_directive_dispatcher_res_groups"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_form" model="ir.ui.view">
|
||||
<field name="name">directive.form</field>
|
||||
<field name="model">directive.directive</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button
|
||||
string="Отменить"
|
||||
type="object"
|
||||
name="action_cancel"
|
||||
class="btn-secondary"
|
||||
/>
|
||||
<button
|
||||
string="Удалить"
|
||||
type="object"
|
||||
name="action_open_delete_wizard"
|
||||
class="btn-danger"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="deadline_at"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="load_percent"/>
|
||||
<field name="priority"/>
|
||||
<field name="work_type_id"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="executor_type"/>
|
||||
<field name="success_probability"/>
|
||||
<field name="resource_cost"/>
|
||||
<field name="retry_policy"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="executor_user_id"/>
|
||||
<field name="executor_role_id"/>
|
||||
<field name="requester_role_id"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="planned_start_at"/>
|
||||
<field name="planned_end_at"/>
|
||||
</group>
|
||||
|
||||
<group string="Статусы">
|
||||
<field name="stage_id"/>
|
||||
<field name="executor_stage_id"/>
|
||||
<field name="requester_stage_id"/>
|
||||
</group>
|
||||
|
||||
<group string="Светофоры">
|
||||
<field name="executor_traffic_light"/>
|
||||
<field name="resource_traffic_light"/>
|
||||
</group>
|
||||
|
||||
<group string="Источник">
|
||||
<field name="origin_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_my_work" model="ir.actions.act_window">
|
||||
<field name="name">Моя работа</field>
|
||||
<field name="res_model">directive.directive</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="domain">[]</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_all" model="ir.actions.act_window">
|
||||
<field name="name">Все директивы</field>
|
||||
<field name="res_model">directive.directive</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="domain">[]</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_directive_root"
|
||||
name="Директивы"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_directive_all"
|
||||
name="Все директивы"
|
||||
parent="menu_directive_root"
|
||||
action="action_directive_all"
|
||||
sequence="20"/>
|
||||
|
||||
<record id="action_directive_origin" model="ir.actions.act_window">
|
||||
<field name="name">Источники директив</field>
|
||||
<field name="res_model">directive.origin</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_stage" model="ir.actions.act_window">
|
||||
<field name="name">Статусы директив</field>
|
||||
<field name="res_model">directive.stage</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_work_type" model="ir.actions.act_window">
|
||||
<field name="name">Виды работ</field>
|
||||
<field name="res_model">directive.work.type</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_template" model="ir.actions.act_window">
|
||||
<field name="name">Шаблоны директив</field>
|
||||
<field name="res_model">directive.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_directive_template_group" model="ir.actions.act_window">
|
||||
<field name="name">Группы шаблонов директив</field>
|
||||
<field name="res_model">directive.template.group</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_settings_root"
|
||||
name="Настройки"
|
||||
parent="directive_layer.menu_directive_root"
|
||||
sequence="90"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_origin"
|
||||
name="Источники директив"
|
||||
parent="directive_layer.menu_directive_settings_root"
|
||||
action="directive_layer.action_directive_origin"
|
||||
sequence="10"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_stage"
|
||||
name="Статусы директив"
|
||||
parent="directive_layer.menu_directive_settings_root"
|
||||
action="directive_layer.action_directive_stage"
|
||||
sequence="20"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_work_type"
|
||||
name="Виды работ"
|
||||
parent="directive_layer.menu_directive_settings_root"
|
||||
action="directive_layer.action_directive_work_type"
|
||||
sequence="30"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_template"
|
||||
name="Шаблоны директив"
|
||||
parent="directive_layer.menu_directive_settings_root"
|
||||
action="directive_layer.action_directive_template"
|
||||
sequence="40"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_directive_template_group"
|
||||
name="Группы шаблонов директив"
|
||||
parent="directive_layer.menu_directive_settings_root"
|
||||
action="directive_layer.action_directive_template_group"
|
||||
sequence="50"
|
||||
/>
|
||||
</odoo>
|
||||
2
directive_layer/wizard/__init__.py
Normal file
2
directive_layer/wizard/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import directive_delete_wizard
|
||||
from . import directive_choose_group_wizard
|
||||
35
directive_layer/wizard/directive_choose_group_wizard.py
Normal file
35
directive_layer/wizard/directive_choose_group_wizard.py
Normal file
@ -0,0 +1,35 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class DirectiveChooseGroupWizard(models.TransientModel):
|
||||
_name = "directive.choose.group.wizard"
|
||||
_description = "Choose Directive Template Group"
|
||||
|
||||
template_group_id = fields.Many2one(
|
||||
"directive.template.group",
|
||||
string="Группа шаблонов",
|
||||
required=True,
|
||||
# domain=lambda self: self._domain_template_group(),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _domain_template_group(self):
|
||||
active_model = self.env.context.get("active_model")
|
||||
return ["|", ("applicable_model", "=", False), ("applicable_model", "=", active_model or False)]
|
||||
|
||||
def action_confirm(self):
|
||||
active_model = self.env.context.get("active_model")
|
||||
active_id = self.env.context.get("active_id")
|
||||
if not active_model or not active_id:
|
||||
raise UserError("No active record in context.")
|
||||
|
||||
record = self.env[active_model].browse(active_id).exists()
|
||||
if not record:
|
||||
raise UserError("Source record not found.")
|
||||
|
||||
if not hasattr(record, "directive_template_group_id"):
|
||||
raise UserError("Model does not support directive template group (directive.mixin is not applied).")
|
||||
|
||||
record.write({"directive_template_group_id": self.template_group_id.id})
|
||||
return {"type": "ir.actions.act_window_close"}
|
||||
32
directive_layer/wizard/directive_delete_wizard.py
Normal file
32
directive_layer/wizard/directive_delete_wizard.py
Normal file
@ -0,0 +1,32 @@
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class DirectiveDeleteWizard(models.TransientModel):
|
||||
_name = "directive.delete.wizard"
|
||||
_description = "Directive Delete Wizard"
|
||||
|
||||
mode = fields.Selection(
|
||||
[
|
||||
("archive", "Мягкое удаление (архивировать)"),
|
||||
("delete", "Полное удаление (unlink)"),
|
||||
],
|
||||
string="Режим",
|
||||
required=True,
|
||||
default="archive",
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
active_model = self.env.context.get("active_model")
|
||||
active_id = self.env.context.get("active_id")
|
||||
if not active_model or not active_id:
|
||||
raise UserError("No active record in context.")
|
||||
record = self.env[active_model].browse(active_id).exists()
|
||||
if not record:
|
||||
return {"type": "ir.actions.act_window_close"}
|
||||
|
||||
if self.mode == "archive":
|
||||
record._action_archive()
|
||||
else:
|
||||
record._action_delete_hard()
|
||||
return {"type": "ir.actions.act_window_close"}
|
||||
37
directive_layer/wizard/directive_wizards.xml
Normal file
37
directive_layer/wizard/directive_wizards.xml
Normal file
@ -0,0 +1,37 @@
|
||||
<odoo>
|
||||
<record id="view_directive_choose_group_wizard_form" model="ir.ui.view">
|
||||
<field name="name">directive.choose.group.wizard.form</field>
|
||||
<field name="model">directive.choose.group.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Выбор группы директив">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="template_group_id"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Подтвердить" type="object" name="action_confirm" class="btn-primary"/>
|
||||
<button string="Отмена" special="cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_directive_delete_wizard_form" model="ir.ui.view">
|
||||
<field name="name">directive.delete.wizard.form</field>
|
||||
<field name="model">directive.delete.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Удаление директивы">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="mode"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Выполнить" type="object" name="action_confirm" class="btn-danger"/>
|
||||
<button string="Отмена" special="cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user