362 lines
13 KiB
Python
362 lines
13 KiB
Python
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 |