322 lines
13 KiB
Python
322 lines
13 KiB
Python
# -*- 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
|