Files
Dispatch/Dispatch_V0.1.1/domain/ticket_state_service.py
2026-04-29 08:18:54 +04:00

209 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# hub/ticket/domain/ticket_state_service.py
"""Доменный сервис переходов и представления состояния Ticket."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from .location_catalog import LocationCatalog
from .task import TicketTask
from .ticket_constants import (
HARDWARE_SIGNAL_ADVANCE,
HARDWARE_SIGNAL_INITIALIZE,
STATE_ARCHIVED,
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_REFUSED,
STATE_TODO,
TICKET_STATE_ACTIONS,
TICKET_STATE_COLORS,
TICKET_STATE_NAMES,
)
from .ticket_transition_policy import TicketTransitionPolicy
STATE_NAMES = TICKET_STATE_NAMES
STATE_COLORS = TICKET_STATE_COLORS
@dataclass(frozen=True, slots=True)
class TicketTransitionResult:
"""Результат обработки доменного перехода Ticket."""
changed: bool
task: TicketTask | None
message: str
blocked_reason: str = ""
class TicketStateService:
"""Каноническая state-machine Ticket без зависимости от GUI и COM-сервиса."""
def __init__(
self,
location_catalog: LocationCatalog | None = None,
transition_policy: TicketTransitionPolicy | None = None,
):
self._location_catalog = location_catalog or LocationCatalog()
self._transition_policy = transition_policy or TicketTransitionPolicy()
def handle_hardware_signal(
self,
button_id: int,
hardware_state: int,
current_task: TicketTask | None,
) -> TicketTransitionResult:
"""Обработать аппаратный сигнал и вычислить доменный результат."""
if hardware_state == HARDWARE_SIGNAL_INITIALIZE:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Получен запрос инициализации для кнопки {button_id}.",
)
if hardware_state not in HARDWARE_SIGNAL_ADVANCE:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Неизвестное аппаратное состояние: {hardware_state:02X}.",
blocked_reason="unsupported_hardware_signal",
)
return self.advance_task(button_id, current_task)
def advance_task(self, button_id: int, current_task: TicketTask | None) -> TicketTransitionResult:
"""Перевести задачу в следующее допустимое состояние."""
if current_task is None:
task = self._build_task_for_state(button_id, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Создана новая задача #{button_id} в состоянии '{task.state_name}'.",
)
if current_task.state_code == STATE_ARCHIVED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Архивная задача #{button_id} начала новый цикл.",
)
if current_task.state_code == STATE_REFUSED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Задача #{button_id} сброшена из отказа в новое выполнение.",
)
if current_task.state_code == STATE_COMPLETED:
task = self._reset_task_cycle(current_task, STATE_TODO)
return TicketTransitionResult(
changed=True,
task=task,
message=f"Выполненная задача #{button_id} начала новый цикл.",
)
decision = self._transition_policy.can_advance(current_task)
if not decision.allowed:
return TicketTransitionResult(
changed=False,
task=current_task,
message=f"Переход задачи #{button_id} отклонён.",
blocked_reason=decision.reason,
)
next_state = self._next_state(current_task.state_code)
task = self._clone_with_state(current_task, next_state)
return TicketTransitionResult(
changed=True,
task=task,
message=(
f"Задача #{button_id} перешла из состояния "
f"'{STATE_NAMES[current_task.state_code]}' в '{task.state_name}'."
),
)
def mark_task_as_refused(
self,
button_id: int,
current_task: TicketTask | None,
refusal_reason: str = "",
) -> TicketTransitionResult:
"""Перевести задачу в состояние отказа."""
task = current_task or self._build_task_for_state(button_id, STATE_TODO)
refused_task = self._clone_with_state(task, STATE_REFUSED)
refused_task.refused_from_state = task.state_code
refused_task.refusal_reason = str(refusal_reason or "").strip()
return TicketTransitionResult(
changed=True,
task=refused_task,
message=f"Задача #{button_id} переведена в отказ.",
)
def move_task_to_archive(
self,
button_id: int,
current_task: TicketTask | None,
) -> TicketTransitionResult:
"""Переместить задачу в архив как отдельное доменное правило."""
task = current_task or self._build_task_for_state(button_id, STATE_TODO)
archived_task = self._clone_with_state(task, STATE_ARCHIVED)
return TicketTransitionResult(
changed=True,
task=archived_task,
message=f"Задача #{button_id} перемещена в архив.",
)
def _next_state(self, current_state: int) -> int:
if current_state == STATE_TODO:
return STATE_IN_PROGRESS
if current_state == STATE_IN_PROGRESS:
return STATE_CONFIRMATION
if current_state == STATE_CONFIRMATION:
return STATE_COMPLETED
return current_state
def _build_task_for_state(self, button_id: int, state_code: int) -> TicketTask:
"""Создать новую задачу для button_id в заданном состоянии."""
return TicketTask(
task_id=button_id,
location=self._location_catalog.get_location(button_id),
state_code=state_code,
state_name=STATE_NAMES[state_code],
action_text=TICKET_STATE_ACTIONS[state_code],
color_hex=STATE_COLORS[state_code],
created_at=datetime.now(),
completed_at=datetime.now() if state_code == STATE_COMPLETED else None,
)
def _clone_with_state(self, task: TicketTask, state_code: int) -> TicketTask:
"""Склонировать задачу с новым доменным состоянием."""
return TicketTask(
task_id=task.task_id,
location=task.location,
state_code=state_code,
state_name=STATE_NAMES[state_code],
action_text=TICKET_STATE_ACTIONS[state_code],
color_hex=STATE_COLORS[state_code],
created_at=task.created_at,
completed_at=datetime.now() if state_code == STATE_COMPLETED else task.completed_at,
refused_from_state=task.refused_from_state,
refusal_reason=task.refusal_reason,
assigned_specialist=task.assigned_specialist,
specialist_photo=task.specialist_photo,
diagnostic_report_signed=task.diagnostic_report_signed,
repair_report_signed=task.repair_report_signed,
acceptance_report_signed=task.acceptance_report_signed,
sequence_number=task.sequence_number,
)
def _reset_task_cycle(self, task: TicketTask, state_code: int) -> TicketTask:
"""Начать новый цикл задачи на основе архивной или отказной записи."""
fresh_task = self._build_task_for_state(task.task_id, state_code)
fresh_task.location = task.location
return fresh_task