Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View 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",
]

View 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

View 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,
)

View 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",
}

View 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

View 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)

View 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