Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC
This commit is contained in:
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})
|
||||
Reference in New Issue
Block a user