# -*- 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