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,321 @@
# -*- coding: utf-8 -*-
# hub/ticket/application/task_application_service.py
"""Единый application-фасад Ticket поверх state, domain и hardware gateway."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from PySide6.QtCore import QObject, Signal
from error_logger import log_exception
from domain import (
ArchiveRecordSnapshot,
TicketConnectionStatus,
TicketDocumentSnapshot,
TicketHardwareStatus,
TicketStateService,
TicketTaskSnapshot,
)
from domain.task import TicketTask
from domain.ticket_constants import STATE_COMPLETED, STATE_REFUSED
from services import ServiceManager, TicketHardwareGateway
from state import TicketRuntimeState
from .archive_service import ArchiveService
from .document_flow_service import DocumentFlowService
from .report_signing_service import ReportSigningService
from .specialist_assignment_service import SpecialistAssignmentService
class TaskApplicationService(QObject):
"""Канонический application facade Ticket."""
task_updated = Signal(object)
task_removed = Signal(int)
connection_changed = Signal(str, str)
active_view_changed = Signal(str)
state_loaded = Signal()
com_connection_changed = Signal(bool, str)
button_initialization_changed = Signal(bool, int)
def __init__(
self,
state: TicketRuntimeState | None = None,
hardware_gateway: TicketHardwareGateway | None = None,
state_service: TicketStateService | None = None,
specialist_service: SpecialistAssignmentService | None = None,
report_service: ReportSigningService | None = None,
archive_service: ArchiveService | None = None,
document_service: DocumentFlowService | None = None,
parent: QObject | None = None,
):
super().__init__(parent)
self._state = state or TicketRuntimeState()
self._hardware_gateway = hardware_gateway or ServiceManager(parent=self)
self._state_service = state_service or TicketStateService()
self._specialist_service = specialist_service or SpecialistAssignmentService(self._state)
self._report_service = report_service or ReportSigningService(self._state)
self._document_service = document_service or DocumentFlowService(self._state)
self._archive_service = archive_service or ArchiveService(
self._state,
self._state_service,
document_service=self._document_service,
)
self._started = False
self._connect_state_signals()
def start(self) -> None:
"""Загрузить state, синхронизировать gateway и запустить сервисы."""
if self._started:
return
self._started = True
self._state.load()
self._hardware_gateway.reset_button_states()
for task in self._state.list_tasks():
self._hardware_gateway.set_button_state(task.task_id, task.state_code)
self._hardware_gateway.set_observer(self)
self._hardware_gateway.start()
def stop(self) -> None:
"""Остановить gateway и снять observer."""
if not self._started:
return
self._started = False
self._hardware_gateway.set_observer(None)
self._hardware_gateway.stop()
def list_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_tasks())
def list_active_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_active_tasks())
def list_archived_tasks(self) -> list[TicketTaskSnapshot]:
return list(self._state.list_archived_tasks())
def list_archive_records(self) -> list[ArchiveRecordSnapshot]:
return self._archive_service.list_archive_records()
def get_task(self, task_id: int) -> TicketTaskSnapshot | None:
return self._state.get_task(task_id)
def handle_task_action(self, raw_action: Mapping[str, Any]) -> TicketTaskSnapshot | None:
"""Обработать аппаратное действие через доменную state-machine."""
button_id = self._normalize_int(raw_action.get("button_id"))
hardware_state = self._normalize_int(raw_action.get("hardware_state"))
if button_id is None or hardware_state is None:
return None
current_snapshot = self._state.get_task(button_id)
current_task = (
TicketTask.from_snapshot(current_snapshot)
if current_snapshot is not None
else None
)
result = self._state_service.handle_hardware_signal(
button_id,
hardware_state,
current_task,
)
if result.task is not None:
self._assign_sequence_on_terminal_state(result.task)
updated_snapshot = result.task.to_snapshot()
self._state.upsert_task(updated_snapshot)
self._hardware_gateway.set_button_state(
updated_snapshot.task_id,
updated_snapshot.state_code,
)
self._auto_save_archive_record(updated_snapshot)
return updated_snapshot
if current_snapshot is not None:
self._hardware_gateway.set_button_state(
current_snapshot.task_id,
current_snapshot.state_code,
)
return current_snapshot
def set_active_view(self, view_name: str) -> None:
self._state.active_view = view_name
def submit_new_task(self, snapshot: TicketTaskSnapshot) -> TicketTaskSnapshot | None:
"""Зарегистрировать заявку, созданную через форму Dispatch.
Назначение: добавляет в runtime-state готовый снимок задачи в
состоянии «Новая заявка» (`STATE_TODO`). Вызывает `upsert_task`,
который эмитит `task_updated` — доска подхватывает карточку без
дополнительной шины событий. Аппаратный шлюз в Dispatch
отключён (`NullHardwareGateway`), поэтому синхронизация
состояния кнопок не выполняется.
"""
if snapshot is None:
return None
self._state.upsert_task(snapshot)
return self._state.get_task(snapshot.task_id)
def allocate_new_task_id(self) -> int:
"""Выдать свободный идентификатор для заявки, созданной диспетчером."""
existing_ids = [task.task_id for task in self._state.list_tasks()]
base = max(existing_ids, default=0)
# Резервируем диапазон 1..99 за аппаратными button_id, чтобы при
# будущей реальной интеграции с COM-каналом идентификаторы заявок
# из формы не пересекались с физическими кнопками.
return max(base, 99) + 1
def assign_specialist(
self,
task_id: int,
specialist_name: str,
) -> TicketTaskSnapshot | None:
snapshot = self._specialist_service.assign_specialist(task_id, specialist_name)
return self._sync_gateway_state(snapshot)
def list_specialists(self) -> list[str]:
return self._specialist_service.list_specialists()
def sign_report(
self,
task_id: int,
report_type: str,
) -> TicketTaskSnapshot | None:
snapshot = self._report_service.sign_report(task_id, report_type)
return self._sync_gateway_state(snapshot)
def sign_acceptance_report(self, task_id: int) -> TicketTaskSnapshot | None:
snapshot = self._report_service.sign_acceptance_report(task_id)
return self._sync_gateway_state(snapshot)
def archive_task(self, task_id: int) -> TicketTaskSnapshot | None:
snapshot = self._archive_service.archive_task(task_id)
return self._sync_gateway_state(snapshot)
def refuse_task(
self,
task_id: int,
refusal_reason: str,
) -> TicketTaskSnapshot | None:
normalized_reason = str(refusal_reason or "").strip()
if not normalized_reason:
return None
current_snapshot = self._state.get_task(task_id)
current_task = (
TicketTask.from_snapshot(current_snapshot)
if current_snapshot is not None
else None
)
result = self._state_service.mark_task_as_refused(
task_id,
current_task,
normalized_reason,
)
if result.task is None:
return None
self._assign_sequence_on_terminal_state(result.task)
snapshot = result.task.to_snapshot()
self._state.upsert_task(snapshot)
self._auto_save_archive_record(snapshot)
return self._sync_gateway_state(snapshot)
def create_diagnostic_report(
self, task_id: int, initial_cause: str, actual_cause: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_diagnostic_report(
task_id, initial_cause, actual_cause,
)
def create_repair_report(
self, task_id: int, work_done: str, used_parts: str, recommendations: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_repair_report(
task_id, work_done, used_parts, recommendations,
)
def create_acceptance_report(
self, task_id: int, work_description: str,
executor_signature: str, customer_signature: str,
) -> TicketDocumentSnapshot:
return self._document_service.create_acceptance_report(
task_id, work_description, executor_signature, customer_signature,
)
def list_documents(
self,
document_type: str | None = None,
) -> list[TicketDocumentSnapshot]:
return self._document_service.list_documents(document_type)
def can_advance_to_confirmation(self, task_id: int) -> bool:
return self._report_service.can_advance_to_confirmation(task_id)
def can_advance_to_completed(self, task_id: int) -> bool:
return self._report_service.can_advance_to_completed(task_id)
def get_active_view(self) -> str:
return self._state.active_view
def get_gateway_status(self) -> TicketHardwareStatus:
return self._hardware_gateway.get_status()
def on_task_action(self, raw_action: Mapping[str, Any]) -> None:
"""Observer callback от hardware gateway."""
try:
self.handle_task_action(raw_action)
except Exception as exc:
log_exception(__name__, "TaskApplicationService.on_task_action", exc)
def on_gateway_status(self, status: TicketHardwareStatus) -> None:
"""Observer callback обновления статуса hardware gateway."""
if status.connection_status == TicketConnectionStatus.ERROR:
self._state.set_error(status.message)
else:
self._state.connection_status = status.connection_status
self._state.set_com_connection(
status.connection_status == TicketConnectionStatus.CONNECTED,
status.message,
)
self._state.set_button_initialization(
status.buttons_initialized,
status.button_count,
)
def on_gateway_error(self, message: str) -> None:
"""Observer callback ошибки hardware gateway."""
self._state.set_error(message)
def _connect_state_signals(self) -> None:
self._state.task_updated.connect(self.task_updated.emit)
self._state.task_removed.connect(self.task_removed.emit)
self._state.connection_changed.connect(self.connection_changed.emit)
self._state.active_view_changed.connect(self.active_view_changed.emit)
self._state.state_loaded.connect(self.state_loaded.emit)
self._state.com_connection_changed.connect(self.com_connection_changed.emit)
self._state.button_initialization_changed.connect(
self.button_initialization_changed.emit
)
def _sync_gateway_state(
self,
snapshot: TicketTaskSnapshot | None,
) -> TicketTaskSnapshot | None:
if snapshot is None:
return None
self._hardware_gateway.set_button_state(snapshot.task_id, snapshot.state_code)
return snapshot
def _assign_sequence_on_terminal_state(self, task: TicketTask) -> None:
"""Присвоить сквозной номер при переходе в Выполненные/Отказ."""
if task.state_code in {STATE_COMPLETED, STATE_REFUSED}:
task.sequence_number = self._state.next_sequence_number()
def _auto_save_archive_record(self, snapshot: TicketTaskSnapshot) -> None:
if snapshot.state_code in {STATE_COMPLETED, STATE_REFUSED}:
self._archive_service.ensure_archive_record(snapshot)
@staticmethod
def _normalize_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None