Add Dispatch_V0.1.1
This commit is contained in:
43
Dispatch_V0.1.1/domain/__init__.py
Normal file
43
Dispatch_V0.1.1/domain/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/__init__.py
|
||||
|
||||
"""Доменные типы Ticket."""
|
||||
|
||||
from .ticket_types import (
|
||||
ArchiveRecordSnapshot,
|
||||
TicketConnectionStatus,
|
||||
TicketDocumentSnapshot,
|
||||
TicketHardwareStatus,
|
||||
TicketTaskSnapshot,
|
||||
)
|
||||
from .location_catalog import LocationCatalog, parse_location_parts
|
||||
from .task import TicketTask
|
||||
from .ticket_constants import (
|
||||
HARDWARE_SIGNAL_ADVANCE,
|
||||
HARDWARE_SIGNAL_INITIALIZE,
|
||||
TICKET_STATE_ACTIONS,
|
||||
TICKET_STATE_COLORS,
|
||||
TICKET_STATE_NAMES,
|
||||
)
|
||||
from .ticket_state_service import TicketStateService, TicketTransitionResult
|
||||
from .ticket_transition_policy import TicketTransitionPolicy, TransitionDecision
|
||||
|
||||
__all__ = [
|
||||
"ArchiveRecordSnapshot",
|
||||
"HARDWARE_SIGNAL_ADVANCE",
|
||||
"HARDWARE_SIGNAL_INITIALIZE",
|
||||
"LocationCatalog",
|
||||
"TicketDocumentSnapshot",
|
||||
"TicketConnectionStatus",
|
||||
"TicketHardwareStatus",
|
||||
"TicketStateService",
|
||||
"TicketTask",
|
||||
"TicketTaskSnapshot",
|
||||
"TicketTransitionPolicy",
|
||||
"TicketTransitionResult",
|
||||
"TICKET_STATE_ACTIONS",
|
||||
"TICKET_STATE_COLORS",
|
||||
"TICKET_STATE_NAMES",
|
||||
"TransitionDecision",
|
||||
"parse_location_parts",
|
||||
]
|
||||
BIN
Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc
Normal file
Binary file not shown.
54
Dispatch_V0.1.1/domain/location_catalog.py
Normal file
54
Dispatch_V0.1.1/domain/location_catalog.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/location_catalog.py
|
||||
|
||||
"""Справочник соответствия button_id и локации Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
DEFAULT_BUTTON_LOCATIONS = {
|
||||
1: "ГБУЗ ФМБА ГП№1 (томограф Siemens, каб. 101)",
|
||||
2: "ГБУЗ ФМБА ГП№2 (ангиограф Амико, каб. 205)",
|
||||
3: "ГБУЗ ФМБА ГП№3 (рентген Philips, каб. 112)",
|
||||
4: "ГБУЗ ФМБА ГП№4 (рентген Shimadzu, каб. 305)",
|
||||
5: "ГБУЗ ФМБА ГП№5 (рентген Toshiba, каб. 208)",
|
||||
6: "ГБУЗ ФМБА ГП№6 (рентген GE, каб. 410)",
|
||||
7: "ГБУЗ ФМБА ГП№7 (рентген Canon, каб. 312)",
|
||||
8: "ГБУЗ ФМБА ГП№8 (рентген Hitachi, каб. 415)",
|
||||
}
|
||||
|
||||
|
||||
class LocationCatalog:
|
||||
"""Канонический справочник локаций Ticket."""
|
||||
|
||||
def __init__(self, locations: Mapping[int, str] | None = None):
|
||||
self._locations = dict(DEFAULT_BUTTON_LOCATIONS)
|
||||
if locations:
|
||||
self._locations.update({int(key): value for key, value in locations.items()})
|
||||
|
||||
def get_location(self, button_id: int) -> str:
|
||||
"""Вернуть локацию по button_id или fallback-описание."""
|
||||
return self._locations.get(button_id, f"Неизвестная локация #{button_id}")
|
||||
|
||||
|
||||
def parse_location_parts(location: str) -> tuple[str, str, str]:
|
||||
"""Разобрать строку локации на учреждение, кабинет и оборудование."""
|
||||
normalized_location = str(location or "").strip()
|
||||
if not normalized_location:
|
||||
return "", "", ""
|
||||
if "(" not in normalized_location or ")" not in normalized_location:
|
||||
return normalized_location, "", ""
|
||||
|
||||
institution = normalized_location.split("(", 1)[0].strip()
|
||||
inside_brackets = normalized_location.split("(", 1)[1].split(")", 1)[0].strip()
|
||||
if not inside_brackets:
|
||||
return institution, "", ""
|
||||
if "каб." not in inside_brackets:
|
||||
return institution, "", inside_brackets
|
||||
|
||||
device_part, room_part = inside_brackets.split("каб.", 1)
|
||||
room = f"каб. {room_part.strip().rstrip(',')}"
|
||||
device = device_part.strip().rstrip(",")
|
||||
return institution, room, device
|
||||
168
Dispatch_V0.1.1/domain/task.py
Normal file
168
Dispatch_V0.1.1/domain/task.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/task.py
|
||||
|
||||
"""Доменная сущность задачи Ticket и сериализация её состояния."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .ticket_types import TicketTaskSnapshot
|
||||
|
||||
|
||||
def _normalize_datetime(value: Any) -> datetime | None:
|
||||
"""Преобразовать строку или datetime в datetime."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
if "T" in value:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_color(value: Any) -> str:
|
||||
"""Нормализовать представление цвета до hex-строки."""
|
||||
if isinstance(value, str) and value.startswith("#") and len(value) >= 4:
|
||||
return value
|
||||
name_getter = getattr(value, "name", None)
|
||||
if callable(name_getter):
|
||||
try:
|
||||
normalized = name_getter()
|
||||
if isinstance(normalized, str) and normalized.startswith("#"):
|
||||
return normalized
|
||||
except Exception as _exc:
|
||||
return "#FFFFFF"
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def _normalize_int(value: Any, default: int | None = None) -> int | None:
|
||||
"""Нормализовать число, сохранив валидное значение 0."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TicketTask:
|
||||
"""Каноническая доменная задача Ticket."""
|
||||
|
||||
task_id: int
|
||||
location: str
|
||||
state_code: int
|
||||
state_name: str
|
||||
action_text: str = ""
|
||||
color_hex: str = "#FFFFFF"
|
||||
created_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
refused_from_state: int | None = None
|
||||
refusal_reason: str = ""
|
||||
assigned_specialist: str = ""
|
||||
specialist_photo: str = ""
|
||||
diagnostic_report_signed: bool = False
|
||||
repair_report_signed: bool = False
|
||||
acceptance_report_signed: bool = False
|
||||
sequence_number: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: Mapping[str, Any]) -> TicketTask | None:
|
||||
"""Создать задачу из записи файлового хранилища."""
|
||||
raw_task_id = record.get("button_id")
|
||||
if raw_task_id is None:
|
||||
return None
|
||||
try:
|
||||
task_id = int(raw_task_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
location=str(record.get("location", "")),
|
||||
state_code=_normalize_int(record.get("state"), 1) or 0,
|
||||
state_name=str(record.get("state_name", "")),
|
||||
action_text=str(record.get("action", "")),
|
||||
color_hex=_normalize_color(record.get("color", "#FFFFFF")),
|
||||
created_at=_normalize_datetime(record.get("created_time")) or datetime.now(),
|
||||
completed_at=_normalize_datetime(record.get("completed_time")),
|
||||
refused_from_state=_normalize_int(record.get("refused_from_state")),
|
||||
refusal_reason=str(record.get("refusal_reason", "")),
|
||||
assigned_specialist=str(record.get("assigned_specialist", "")),
|
||||
specialist_photo=str(record.get("specialist_photo", "")),
|
||||
diagnostic_report_signed=bool(record.get("diagnostic_report_signed", False)),
|
||||
repair_report_signed=bool(record.get("repair_report_signed", False)),
|
||||
acceptance_report_signed=bool(record.get("acceptance_report_signed", False)),
|
||||
sequence_number=_normalize_int(record.get("sequence_number"), 0) or 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_snapshot(cls, snapshot: TicketTaskSnapshot) -> TicketTask:
|
||||
"""Создать доменную сущность из snapshot."""
|
||||
return cls(
|
||||
task_id=snapshot.task_id,
|
||||
location=snapshot.location,
|
||||
state_code=snapshot.state_code,
|
||||
state_name=snapshot.state_name,
|
||||
action_text=snapshot.action_text,
|
||||
color_hex=snapshot.color_hex,
|
||||
created_at=snapshot.created_at,
|
||||
completed_at=snapshot.completed_at,
|
||||
refused_from_state=snapshot.refused_from_state,
|
||||
refusal_reason=snapshot.refusal_reason,
|
||||
assigned_specialist=snapshot.assigned_specialist,
|
||||
specialist_photo=snapshot.specialist_photo,
|
||||
diagnostic_report_signed=snapshot.diagnostic_report_signed,
|
||||
repair_report_signed=snapshot.repair_report_signed,
|
||||
acceptance_report_signed=snapshot.acceptance_report_signed,
|
||||
sequence_number=snapshot.sequence_number,
|
||||
)
|
||||
|
||||
def to_record(self) -> dict[str, Any]:
|
||||
"""Преобразовать доменную сущность в запись для JSON."""
|
||||
return {
|
||||
"button_id": self.task_id,
|
||||
"location": self.location,
|
||||
"state": self.state_code,
|
||||
"action": self.action_text,
|
||||
"state_name": self.state_name,
|
||||
"color": self.color_hex,
|
||||
"created_time": self.created_at,
|
||||
"completed_time": self.completed_at,
|
||||
"refused_from_state": self.refused_from_state,
|
||||
"refusal_reason": self.refusal_reason,
|
||||
"assigned_specialist": self.assigned_specialist,
|
||||
"specialist_photo": self.specialist_photo,
|
||||
"diagnostic_report_signed": self.diagnostic_report_signed,
|
||||
"repair_report_signed": self.repair_report_signed,
|
||||
"acceptance_report_signed": self.acceptance_report_signed,
|
||||
"sequence_number": self.sequence_number,
|
||||
}
|
||||
|
||||
def to_snapshot(self) -> TicketTaskSnapshot:
|
||||
"""Вернуть неизменяемый снимок задачи для внешних слоёв."""
|
||||
return TicketTaskSnapshot(
|
||||
task_id=self.task_id,
|
||||
location=self.location,
|
||||
state_code=self.state_code,
|
||||
state_name=self.state_name,
|
||||
action_text=self.action_text,
|
||||
color_hex=self.color_hex,
|
||||
created_at=self.created_at,
|
||||
completed_at=self.completed_at,
|
||||
refused_from_state=self.refused_from_state,
|
||||
refusal_reason=self.refusal_reason,
|
||||
assigned_specialist=self.assigned_specialist,
|
||||
specialist_photo=self.specialist_photo,
|
||||
diagnostic_report_signed=self.diagnostic_report_signed,
|
||||
repair_report_signed=self.repair_report_signed,
|
||||
acceptance_report_signed=self.acceptance_report_signed,
|
||||
sequence_number=self.sequence_number,
|
||||
)
|
||||
41
Dispatch_V0.1.1/domain/ticket_constants.py
Normal file
41
Dispatch_V0.1.1/domain/ticket_constants.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/ticket_constants.py
|
||||
|
||||
"""Константы доменной state-machine Ticket."""
|
||||
|
||||
STATE_TODO = 1
|
||||
STATE_IN_PROGRESS = 2
|
||||
STATE_CONFIRMATION = 3
|
||||
STATE_COMPLETED = 0
|
||||
STATE_REFUSED = 4
|
||||
STATE_ARCHIVED = 5
|
||||
|
||||
HARDWARE_SIGNAL_ADVANCE = frozenset({0, 1, 2, 3})
|
||||
HARDWARE_SIGNAL_INITIALIZE = 0xFF
|
||||
|
||||
TICKET_STATE_NAMES = {
|
||||
STATE_TODO: "К выполнению",
|
||||
STATE_IN_PROGRESS: "В работе",
|
||||
STATE_CONFIRMATION: "Подтверждение",
|
||||
STATE_COMPLETED: "Выполненные",
|
||||
STATE_REFUSED: "Отказ в обслуживании",
|
||||
STATE_ARCHIVED: "Архив",
|
||||
}
|
||||
|
||||
TICKET_STATE_ACTIONS = {
|
||||
STATE_TODO: "Инженер направлен",
|
||||
STATE_IN_PROGRESS: "Выполняются работы",
|
||||
STATE_CONFIRMATION: "Ожидает подтверждения",
|
||||
STATE_COMPLETED: "Работа завершена",
|
||||
STATE_REFUSED: "Отказ в обслуживании",
|
||||
STATE_ARCHIVED: "Перемещено в архив",
|
||||
}
|
||||
|
||||
TICKET_STATE_COLORS = {
|
||||
STATE_TODO: "#FF5938",
|
||||
STATE_IN_PROGRESS: "#008BFA",
|
||||
STATE_CONFIRMATION: "#FFD27A",
|
||||
STATE_COMPLETED: "#36AC87",
|
||||
STATE_REFUSED: "#D1D5DB",
|
||||
STATE_ARCHIVED: "#9CA3AF",
|
||||
}
|
||||
208
Dispatch_V0.1.1/domain/ticket_state_service.py
Normal file
208
Dispatch_V0.1.1/domain/ticket_state_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
# -*- 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
|
||||
53
Dispatch_V0.1.1/domain/ticket_transition_policy.py
Normal file
53
Dispatch_V0.1.1/domain/ticket_transition_policy.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/ticket_transition_policy.py
|
||||
|
||||
"""Политика допусков переходов между состояниями Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .task import TicketTask
|
||||
from .ticket_constants import (
|
||||
STATE_COMPLETED,
|
||||
STATE_CONFIRMATION,
|
||||
STATE_IN_PROGRESS,
|
||||
STATE_TODO,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransitionDecision:
|
||||
"""Результат проверки допуска перехода."""
|
||||
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class TicketTransitionPolicy:
|
||||
"""Доменные проверки допуска переходов Ticket."""
|
||||
|
||||
def can_advance(self, task: TicketTask) -> TransitionDecision:
|
||||
"""Проверить возможность перехода в следующее состояние."""
|
||||
if task.state_code == STATE_TODO and not task.assigned_specialist.strip():
|
||||
return TransitionDecision(
|
||||
allowed=False,
|
||||
reason="Без назначенного специалиста нельзя перейти в 'В работе'.",
|
||||
)
|
||||
if task.state_code == STATE_IN_PROGRESS:
|
||||
if not task.diagnostic_report_signed or not task.repair_report_signed:
|
||||
return TransitionDecision(
|
||||
allowed=False,
|
||||
reason="Без двух подписанных отчётов нельзя перейти в 'Подтверждение'.",
|
||||
)
|
||||
if task.state_code == STATE_CONFIRMATION and not task.acceptance_report_signed:
|
||||
return TransitionDecision(
|
||||
allowed=False,
|
||||
reason="Без акта приёмки нельзя перейти в 'Выполненные'.",
|
||||
)
|
||||
if task.state_code == STATE_COMPLETED:
|
||||
return TransitionDecision(
|
||||
allowed=False,
|
||||
reason="Выполненная задача не должна принимать лишние сигналы до архивации.",
|
||||
)
|
||||
return TransitionDecision(allowed=True)
|
||||
92
Dispatch_V0.1.1/domain/ticket_types.py
Normal file
92
Dispatch_V0.1.1/domain/ticket_types.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/domain/ticket_types.py
|
||||
|
||||
"""Базовые доменные типы Ticket без зависимости от legacy GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class TicketConnectionStatus(str, Enum):
|
||||
"""Нормализованный статус аппаратного подключения Ticket."""
|
||||
|
||||
CONNECTED = "connected"
|
||||
DISCONNECTED = "disconnected"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketTaskSnapshot:
|
||||
"""Плоский снимок задачи для обмена между слоями."""
|
||||
|
||||
task_id: int
|
||||
location: str
|
||||
state_code: int
|
||||
state_name: str
|
||||
action_text: str = ""
|
||||
color_hex: str = "#FFFFFF"
|
||||
created_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
refused_from_state: int | None = None
|
||||
refusal_reason: str = ""
|
||||
assigned_specialist: str = ""
|
||||
specialist_photo: str = ""
|
||||
diagnostic_report_signed: bool = False
|
||||
repair_report_signed: bool = False
|
||||
acceptance_report_signed: bool = False
|
||||
sequence_number: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketDocumentSnapshot:
|
||||
"""Плоский снимок сохранённого документа Ticket."""
|
||||
|
||||
document_id: str
|
||||
task_id: int
|
||||
document_type: str
|
||||
title: str
|
||||
created_at: datetime
|
||||
location: str = ""
|
||||
specialist_name: str = ""
|
||||
summary: str = ""
|
||||
content: str = ""
|
||||
storage_path: str = ""
|
||||
payload: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketHardwareStatus:
|
||||
"""Снимок статуса аппаратного шлюза."""
|
||||
|
||||
connection_status: TicketConnectionStatus = TicketConnectionStatus.DISCONNECTED
|
||||
message: str = ""
|
||||
buttons_initialized: bool = False
|
||||
button_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ArchiveRecordSnapshot:
|
||||
"""Плоский снимок архивной записи по завершённой или отказной задаче."""
|
||||
|
||||
task_id: int
|
||||
location: str
|
||||
pre_archive_state_code: int
|
||||
pre_archive_state_name: str
|
||||
color_hex: str = "#FFFFFF"
|
||||
created_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
refused_from_state: int | None = None
|
||||
refusal_reason: str = ""
|
||||
assigned_specialist: str = ""
|
||||
specialist_photo: str = ""
|
||||
diagnostic_report_signed: bool = False
|
||||
repair_report_signed: bool = False
|
||||
acceptance_report_signed: bool = False
|
||||
document_ids: tuple[str, ...] = ()
|
||||
action_text: str = ""
|
||||
sequence_number: int = 0
|
||||
Reference in New Issue
Block a user