Add Dispatch_V0.1.1
This commit is contained in:
25
Dispatch_V0.1.1/state/__init__.py
Normal file
25
Dispatch_V0.1.1/state/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/__init__.py
|
||||
|
||||
"""Публичный state-контур Ticket."""
|
||||
|
||||
from .document_repository import TicketDocumentRepository
|
||||
from .paths import ACTS_DIR, ARCHIVE_DIR, DATA_DIR, REPORTS_DIR, ROOT_DIR, TASKS_FILE
|
||||
from .archive_record_repository import ArchiveRecordRepository
|
||||
from .repository import TicketStateRepository
|
||||
from .runtime_state import TicketRuntimeState
|
||||
from .ticket_state_api import TicketStateApi
|
||||
|
||||
__all__ = [
|
||||
"ACTS_DIR",
|
||||
"ARCHIVE_DIR",
|
||||
"ArchiveRecordRepository",
|
||||
"DATA_DIR",
|
||||
"REPORTS_DIR",
|
||||
"ROOT_DIR",
|
||||
"TASKS_FILE",
|
||||
"TicketDocumentRepository",
|
||||
"TicketRuntimeState",
|
||||
"TicketStateApi",
|
||||
"TicketStateRepository",
|
||||
]
|
||||
BIN
Dispatch_V0.1.1/state/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/state/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Dispatch_V0.1.1/state/__pycache__/paths.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/state/__pycache__/paths.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Dispatch_V0.1.1/state/__pycache__/repository.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/state/__pycache__/repository.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Dispatch_V0.1.1/state/__pycache__/runtime_state.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/state/__pycache__/runtime_state.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
195
Dispatch_V0.1.1/state/archive_record_repository.py
Normal file
195
Dispatch_V0.1.1/state/archive_record_repository.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/archive_record_repository.py
|
||||
|
||||
"""Файловый репозиторий архивных записей Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from error_logger import log_exception
|
||||
|
||||
from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot, TicketTaskSnapshot
|
||||
from .paths import ARCHIVE_DIR, ensure_storage_directories
|
||||
|
||||
|
||||
class ArchiveRecordRepository:
|
||||
"""Каноническое файловое хранилище архивных записей Ticket."""
|
||||
|
||||
def __init__(self, archive_dir: Path = ARCHIVE_DIR):
|
||||
self._archive_dir = archive_dir
|
||||
ensure_storage_directories()
|
||||
self._archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def has_record(self, task_id: int, cycle_token: str = "") -> bool:
|
||||
"""Проверить, существует ли запись для задачи (и цикла)."""
|
||||
if not cycle_token:
|
||||
pattern = f"archive_task_{task_id}_*.json"
|
||||
return any(self._archive_dir.glob(pattern))
|
||||
for path in self._archive_dir.glob(f"archive_task_{task_id}_*.json"):
|
||||
record = self._load_record(path)
|
||||
if record is None:
|
||||
continue
|
||||
if record.created_at is not None:
|
||||
token = record.created_at.strftime("%Y%m%d_%H%M%S")
|
||||
if token == cycle_token:
|
||||
return True
|
||||
return False
|
||||
|
||||
def save_record(
|
||||
self,
|
||||
task: TicketTaskSnapshot,
|
||||
documents: list[TicketDocumentSnapshot],
|
||||
) -> ArchiveRecordSnapshot | None:
|
||||
"""Сохранить архивную запись для завершённой/отказной задачи."""
|
||||
now = datetime.now()
|
||||
pre_state_code = self._resolve_pre_archive_state(task)
|
||||
pre_state_name = self._resolve_pre_archive_state_name(task, pre_state_code)
|
||||
document_ids = tuple(doc.document_id for doc in documents)
|
||||
|
||||
record = ArchiveRecordSnapshot(
|
||||
task_id=task.task_id,
|
||||
location=task.location,
|
||||
pre_archive_state_code=pre_state_code,
|
||||
pre_archive_state_name=pre_state_name,
|
||||
color_hex=task.color_hex,
|
||||
created_at=task.created_at,
|
||||
completed_at=task.completed_at,
|
||||
archived_at=now,
|
||||
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,
|
||||
document_ids=document_ids,
|
||||
action_text=task.action_text,
|
||||
sequence_number=task.sequence_number,
|
||||
)
|
||||
|
||||
file_path = self._record_path(task.task_id, now)
|
||||
payload = self._serialize(record)
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "ArchiveRecordRepository.save_record", exc)
|
||||
return None
|
||||
return record
|
||||
|
||||
def list_records(self) -> list[ArchiveRecordSnapshot]:
|
||||
"""Загрузить все архивные записи из каталога."""
|
||||
records: list[ArchiveRecordSnapshot] = []
|
||||
for path in self._archive_dir.glob("*.json"):
|
||||
record = self._load_record(path)
|
||||
if record is not None:
|
||||
records.append(record)
|
||||
records.sort(key=lambda r: r.archived_at or datetime.min, reverse=True)
|
||||
return records
|
||||
|
||||
def _load_record(self, path: Path) -> ArchiveRecordSnapshot | None:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "ArchiveRecordRepository._load_record", exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return self._deserialize(data)
|
||||
|
||||
def _record_path(self, task_id: int, archived_at: datetime) -> Path:
|
||||
stamp = archived_at.strftime("%Y%m%d_%H%M%S")
|
||||
return self._archive_dir / f"archive_task_{task_id}_{stamp}.json"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_pre_archive_state(task: TicketTaskSnapshot) -> int:
|
||||
from ..domain.ticket_constants import STATE_COMPLETED, STATE_REFUSED
|
||||
if task.state_code == STATE_COMPLETED:
|
||||
return STATE_COMPLETED
|
||||
if task.state_code == STATE_REFUSED:
|
||||
return STATE_REFUSED
|
||||
if task.refused_from_state is not None:
|
||||
return STATE_REFUSED
|
||||
return STATE_COMPLETED
|
||||
|
||||
@staticmethod
|
||||
def _resolve_pre_archive_state_name(task: TicketTaskSnapshot, state_code: int) -> str:
|
||||
from ..domain.ticket_constants import STATE_REFUSED
|
||||
if state_code == STATE_REFUSED:
|
||||
return "Отказ в обслуживании"
|
||||
return "Выполненные"
|
||||
|
||||
@staticmethod
|
||||
def _serialize(record: ArchiveRecordSnapshot) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": record.task_id,
|
||||
"location": record.location,
|
||||
"pre_archive_state_code": record.pre_archive_state_code,
|
||||
"pre_archive_state_name": record.pre_archive_state_name,
|
||||
"color_hex": record.color_hex,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
"completed_at": record.completed_at.isoformat() if record.completed_at else None,
|
||||
"archived_at": record.archived_at.isoformat() if record.archived_at else None,
|
||||
"refused_from_state": record.refused_from_state,
|
||||
"refusal_reason": record.refusal_reason,
|
||||
"assigned_specialist": record.assigned_specialist,
|
||||
"specialist_photo": record.specialist_photo,
|
||||
"diagnostic_report_signed": record.diagnostic_report_signed,
|
||||
"repair_report_signed": record.repair_report_signed,
|
||||
"acceptance_report_signed": record.acceptance_report_signed,
|
||||
"document_ids": list(record.document_ids),
|
||||
"action_text": record.action_text,
|
||||
"sequence_number": record.sequence_number,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _deserialize(data: dict[str, Any]) -> ArchiveRecordSnapshot | None:
|
||||
try:
|
||||
task_id = int(data["task_id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return ArchiveRecordSnapshot(
|
||||
task_id=task_id,
|
||||
location=str(data.get("location", "")),
|
||||
pre_archive_state_code=int(data.get("pre_archive_state_code", 0)),
|
||||
pre_archive_state_name=str(data.get("pre_archive_state_name", "")),
|
||||
color_hex=str(data.get("color_hex", "#FFFFFF")),
|
||||
created_at=_parse_datetime(data.get("created_at")),
|
||||
completed_at=_parse_datetime(data.get("completed_at")),
|
||||
archived_at=_parse_datetime(data.get("archived_at")),
|
||||
refused_from_state=_parse_optional_int(data.get("refused_from_state")),
|
||||
refusal_reason=str(data.get("refusal_reason", "")),
|
||||
assigned_specialist=str(data.get("assigned_specialist", "")),
|
||||
specialist_photo=str(data.get("specialist_photo", "")),
|
||||
diagnostic_report_signed=bool(data.get("diagnostic_report_signed")),
|
||||
repair_report_signed=bool(data.get("repair_report_signed")),
|
||||
acceptance_report_signed=bool(data.get("acceptance_report_signed")),
|
||||
document_ids=tuple(data.get("document_ids", ())),
|
||||
action_text=str(data.get("action_text", "")),
|
||||
sequence_number=int(data.get("sequence_number", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_optional_int(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
187
Dispatch_V0.1.1/state/document_repository.py
Normal file
187
Dispatch_V0.1.1/state/document_repository.py
Normal file
@@ -0,0 +1,187 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/document_repository.py
|
||||
|
||||
"""Файловый репозиторий документов Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from error_logger import log_exception
|
||||
|
||||
from domain import TicketDocumentSnapshot, TicketTaskSnapshot
|
||||
from .paths import ACTS_DIR, REPORTS_DIR, ensure_storage_directories
|
||||
|
||||
|
||||
REPORT_DOCUMENT_TYPES = {"diagnostic", "repair"}
|
||||
|
||||
|
||||
class TicketDocumentRepository:
|
||||
"""Каноническое файловое хранилище отчётов и актов Ticket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reports_dir: Path = REPORTS_DIR,
|
||||
acts_dir: Path = ACTS_DIR,
|
||||
):
|
||||
self._reports_dir = reports_dir
|
||||
self._acts_dir = acts_dir
|
||||
ensure_storage_directories()
|
||||
self._reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._acts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_document(
|
||||
self,
|
||||
task: TicketTaskSnapshot,
|
||||
document_type: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
content: str,
|
||||
payload: Mapping[str, str],
|
||||
) -> TicketDocumentSnapshot | None:
|
||||
"""Сохранить документ в каноническое JSON-хранилище."""
|
||||
created_at = datetime.now()
|
||||
document_id = self._build_document_id(task, document_type)
|
||||
storage_path = self._directory_for_type(document_type) / f"{document_id}.json"
|
||||
snapshot = TicketDocumentSnapshot(
|
||||
document_id=document_id,
|
||||
task_id=task.task_id,
|
||||
document_type=document_type,
|
||||
title=title,
|
||||
created_at=created_at,
|
||||
location=task.location,
|
||||
specialist_name=task.assigned_specialist,
|
||||
summary=summary,
|
||||
content=content,
|
||||
storage_path=str(storage_path),
|
||||
payload=dict(payload),
|
||||
)
|
||||
payload_data = self._serialize_snapshot(snapshot)
|
||||
try:
|
||||
with open(storage_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
payload_data,
|
||||
handle,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "TicketDocumentRepository.save_document", exc)
|
||||
return None
|
||||
return snapshot
|
||||
|
||||
def list_documents(
|
||||
self,
|
||||
document_type: str | None = None,
|
||||
) -> list[TicketDocumentSnapshot]:
|
||||
"""Вернуть список документов с optional-фильтром по типу."""
|
||||
snapshots: list[TicketDocumentSnapshot] = []
|
||||
for directory in self._directories_for_filter(document_type):
|
||||
for path in directory.glob("*.json"):
|
||||
snapshot = self._load_snapshot(path)
|
||||
if snapshot is None:
|
||||
continue
|
||||
if document_type == "report" and snapshot.document_type not in REPORT_DOCUMENT_TYPES:
|
||||
continue
|
||||
if document_type not in {None, "report"} and snapshot.document_type != document_type:
|
||||
continue
|
||||
snapshots.append(snapshot)
|
||||
snapshots.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return snapshots
|
||||
|
||||
def _load_snapshot(self, storage_path: Path) -> TicketDocumentSnapshot | None:
|
||||
try:
|
||||
with open(storage_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "TicketDocumentRepository._load_snapshot", exc)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return self._deserialize_snapshot(payload, storage_path)
|
||||
|
||||
def _deserialize_snapshot(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
storage_path: Path,
|
||||
) -> TicketDocumentSnapshot | None:
|
||||
raw_created_at = payload.get("created_at")
|
||||
if not isinstance(raw_created_at, str):
|
||||
return None
|
||||
try:
|
||||
created_at = datetime.fromisoformat(raw_created_at)
|
||||
except ValueError:
|
||||
return None
|
||||
raw_document_id = payload.get("document_id")
|
||||
raw_task_id = payload.get("task_id")
|
||||
raw_document_type = payload.get("document_type")
|
||||
raw_title = payload.get("title")
|
||||
if not all(
|
||||
isinstance(value, str)
|
||||
for value in (raw_document_id, raw_document_type, raw_title)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
task_id = int(raw_task_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
raw_payload = payload.get("payload")
|
||||
snapshot_payload: dict[str, str] = {}
|
||||
if isinstance(raw_payload, dict):
|
||||
snapshot_payload = {
|
||||
str(key): str(value)
|
||||
for key, value in raw_payload.items()
|
||||
if value is not None
|
||||
}
|
||||
return TicketDocumentSnapshot(
|
||||
document_id=raw_document_id,
|
||||
task_id=task_id,
|
||||
document_type=raw_document_type,
|
||||
title=raw_title,
|
||||
created_at=created_at,
|
||||
location=str(payload.get("location", "")),
|
||||
specialist_name=str(payload.get("specialist_name", "")),
|
||||
summary=str(payload.get("summary", "")),
|
||||
content=str(payload.get("content", "")),
|
||||
storage_path=str(storage_path),
|
||||
payload=snapshot_payload,
|
||||
)
|
||||
|
||||
def _serialize_snapshot(self, snapshot: TicketDocumentSnapshot) -> dict[str, Any]:
|
||||
return {
|
||||
"document_id": snapshot.document_id,
|
||||
"task_id": snapshot.task_id,
|
||||
"document_type": snapshot.document_type,
|
||||
"title": snapshot.title,
|
||||
"created_at": snapshot.created_at.isoformat(),
|
||||
"location": snapshot.location,
|
||||
"specialist_name": snapshot.specialist_name,
|
||||
"summary": snapshot.summary,
|
||||
"content": snapshot.content,
|
||||
"storage_path": snapshot.storage_path,
|
||||
"payload": dict(snapshot.payload),
|
||||
}
|
||||
|
||||
def _directories_for_filter(self, document_type: str | None) -> Iterable[Path]:
|
||||
if document_type == "acceptance":
|
||||
return (self._acts_dir,)
|
||||
if document_type in REPORT_DOCUMENT_TYPES or document_type in {None, "report"}:
|
||||
return (self._reports_dir, self._acts_dir) if document_type is None else (self._reports_dir,)
|
||||
return (self._reports_dir, self._acts_dir)
|
||||
|
||||
def _directory_for_type(self, document_type: str) -> Path:
|
||||
if document_type == "acceptance":
|
||||
return self._acts_dir
|
||||
return self._reports_dir
|
||||
|
||||
@staticmethod
|
||||
def _build_document_id(task: TicketTaskSnapshot, document_type: str) -> str:
|
||||
cycle_token = (
|
||||
task.created_at.strftime("%Y%m%d_%H%M%S")
|
||||
if task.created_at is not None
|
||||
else datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
)
|
||||
return f"task_{task.task_id}_{cycle_token}_{document_type}"
|
||||
36
Dispatch_V0.1.1/state/paths.py
Normal file
36
Dispatch_V0.1.1/state/paths.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/paths.py
|
||||
|
||||
"""Пути хранения Ticket внутри общей системы."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
|
||||
def _default_root_dir() -> Path:
|
||||
"""Вернуть корневую директорию хранения Ticket."""
|
||||
base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
|
||||
root_base = Path(base) if base else Path.cwd() / ".usms_data"
|
||||
return root_base / "ticket"
|
||||
|
||||
|
||||
ROOT_DIR = Path(os.environ.get("USMS_TICKET_ROOT_DIR", _default_root_dir()))
|
||||
ACTS_DIR = Path(os.environ.get("USMS_TICKET_ACTS_DIR", ROOT_DIR / "acts"))
|
||||
REPORTS_DIR = Path(os.environ.get("USMS_TICKET_REPORTS_DIR", ROOT_DIR / "reports"))
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
ARCHIVE_DIR = Path(os.environ.get("USMS_TICKET_ARCHIVE_DIR", _PROJECT_ROOT / "DB data"))
|
||||
DATA_DIR = Path(os.environ.get("USMS_TICKET_DATA_DIR", ROOT_DIR / "data"))
|
||||
TASKS_FILE = DATA_DIR / "tasks.json"
|
||||
|
||||
|
||||
def ensure_storage_directories() -> None:
|
||||
"""Создать каталоги хранения Ticket при фактической работе с данными."""
|
||||
ROOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
72
Dispatch_V0.1.1/state/repository.py
Normal file
72
Dispatch_V0.1.1/state/repository.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/repository.py
|
||||
|
||||
"""Файловый репозиторий Ticket для хранения задач."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from error_logger import log_exception
|
||||
|
||||
from domain.task import TicketTask
|
||||
from .paths import ensure_storage_directories
|
||||
|
||||
|
||||
class TicketStateRepository:
|
||||
"""Канонический файловый репозиторий состояния Ticket."""
|
||||
|
||||
def __init__(self, tasks_file: Path):
|
||||
self._tasks_file = tasks_file
|
||||
ensure_storage_directories()
|
||||
self._tasks_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_tasks(self) -> list[TicketTask]:
|
||||
"""Загрузить все задачи из хранилища."""
|
||||
try:
|
||||
if not self._tasks_file.exists():
|
||||
return []
|
||||
with open(self._tasks_file, "r", encoding="utf-8") as handle:
|
||||
raw_tasks = json.load(handle)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "TicketStateRepository.load_tasks", exc)
|
||||
return []
|
||||
|
||||
if not isinstance(raw_tasks, list):
|
||||
return []
|
||||
|
||||
tasks: list[TicketTask] = []
|
||||
for raw_task in raw_tasks:
|
||||
if not isinstance(raw_task, dict):
|
||||
continue
|
||||
task = TicketTask.from_record(raw_task)
|
||||
if task is not None:
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
def save_tasks(self, tasks: list[TicketTask]) -> bool:
|
||||
"""Сохранить все задачи в каноническое JSON-хранилище."""
|
||||
try:
|
||||
payload = [task.to_record() for task in tasks]
|
||||
with open(self._tasks_file, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
payload,
|
||||
handle,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
default=self._json_serializer,
|
||||
)
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "TicketStateRepository.save_tasks", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _json_serializer(value: Any) -> str:
|
||||
"""Сериализовать datetime и родственные значения для JSON."""
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
raise TypeError(f"Object of type {type(value)} is not JSON serializable")
|
||||
251
Dispatch_V0.1.1/state/runtime_state.py
Normal file
251
Dispatch_V0.1.1/state/runtime_state.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/runtime_state.py
|
||||
|
||||
"""Runtime-state Ticket с Qt-сигналами и единым путём сохранения."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from error_logger import log_exception
|
||||
|
||||
from domain import TicketConnectionStatus, TicketTaskSnapshot
|
||||
from domain.task import TicketTask, _normalize_color, _normalize_datetime, _normalize_int
|
||||
from .paths import TASKS_FILE
|
||||
from .repository import TicketStateRepository
|
||||
|
||||
|
||||
class TicketRuntimeState(QObject):
|
||||
"""Канонический runtime-state 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, repository: TicketStateRepository | None = None):
|
||||
super().__init__()
|
||||
self._repository = repository or TicketStateRepository(TASKS_FILE)
|
||||
self._tasks: dict[int, TicketTask] = {}
|
||||
self._connection_status = TicketConnectionStatus.DISCONNECTED
|
||||
self._active_view = "board"
|
||||
self._com_connected = False
|
||||
self._buttons_initialized = False
|
||||
self._button_count = 0
|
||||
self._sequence_counter = 0
|
||||
|
||||
def load(self) -> None:
|
||||
"""Загрузить задачи из репозитория в память."""
|
||||
self._tasks = {task.task_id: task for task in self._repository.load_tasks()}
|
||||
self._sequence_counter = max(
|
||||
(t.sequence_number for t in self._tasks.values()), default=0,
|
||||
)
|
||||
self.state_loaded.emit()
|
||||
for task in self.list_tasks():
|
||||
self.task_updated.emit(task)
|
||||
|
||||
def list_tasks(self) -> list[TicketTaskSnapshot]:
|
||||
"""Вернуть снимки всех активных и архивных задач."""
|
||||
return [task.to_snapshot() for task in self._tasks.values()]
|
||||
|
||||
def list_active_tasks(self) -> list[TicketTaskSnapshot]:
|
||||
"""Вернуть снимки только неархивных задач."""
|
||||
return [task.to_snapshot() for task in self._tasks.values() if task.state_code != 5]
|
||||
|
||||
def list_archived_tasks(self) -> list[TicketTaskSnapshot]:
|
||||
"""Вернуть снимки архивных задач."""
|
||||
return [task.to_snapshot() for task in self._tasks.values() if task.state_code == 5]
|
||||
|
||||
def get_task(self, task_id: int) -> TicketTaskSnapshot | None:
|
||||
"""Вернуть снимок задачи по идентификатору."""
|
||||
task = self._tasks.get(task_id)
|
||||
return task.to_snapshot() if task else None
|
||||
|
||||
def next_sequence_number(self) -> int:
|
||||
"""Вернуть следующий сквозной номер задачи."""
|
||||
self._sequence_counter += 1
|
||||
return self._sequence_counter
|
||||
|
||||
def upsert_task(self, task: TicketTaskSnapshot) -> None:
|
||||
"""Создать или обновить задачу по готовому snapshot."""
|
||||
self._tasks[task.task_id] = TicketTask.from_snapshot(task)
|
||||
self._save_and_emit(task.task_id)
|
||||
|
||||
def update_task(self, task_data: Mapping[str, Any]) -> TicketTaskSnapshot | None:
|
||||
"""Обновить задачу по legacy-совместимому словарю полей."""
|
||||
raw_task_id = task_data.get("button_id")
|
||||
if raw_task_id is None:
|
||||
return None
|
||||
try:
|
||||
task_id = int(raw_task_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None:
|
||||
created_task = TicketTask.from_record(task_data)
|
||||
if created_task is None:
|
||||
return None
|
||||
task = created_task
|
||||
task.created_at = task.created_at or datetime.now()
|
||||
self._tasks[task_id] = task
|
||||
else:
|
||||
self._apply_update(task, task_data)
|
||||
|
||||
self._save_and_emit(task_id)
|
||||
return self.get_task(task_id)
|
||||
|
||||
def remove_task(self, task_id: int) -> None:
|
||||
"""Удалить задачу из runtime-state и хранилища."""
|
||||
if task_id not in self._tasks:
|
||||
return
|
||||
del self._tasks[task_id]
|
||||
self._persist()
|
||||
self.task_removed.emit(task_id)
|
||||
|
||||
def can_advance_to_confirmation(self, task_id: int) -> bool:
|
||||
"""Проверить, что обязательные отчёты подписаны."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
return task.diagnostic_report_signed and task.repair_report_signed
|
||||
|
||||
def sign_report(self, task_id: int, report_type: str) -> None:
|
||||
"""Отметить подписание диагностического или ремонтного отчёта."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None:
|
||||
return
|
||||
if report_type == "diagnostic":
|
||||
task.diagnostic_report_signed = True
|
||||
elif report_type == "repair":
|
||||
task.repair_report_signed = True
|
||||
else:
|
||||
return
|
||||
self._save_and_emit(task_id)
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
"""Перевести статус подключения в ошибку и уведомить подписчиков."""
|
||||
self._connection_status = TicketConnectionStatus.ERROR
|
||||
self.connection_changed.emit(self._connection_status.value, message)
|
||||
|
||||
@property
|
||||
def connection_status(self) -> TicketConnectionStatus:
|
||||
return self._connection_status
|
||||
|
||||
@connection_status.setter
|
||||
def connection_status(self, value: TicketConnectionStatus) -> None:
|
||||
if self._connection_status == value:
|
||||
return
|
||||
self._connection_status = value
|
||||
self.connection_changed.emit(value.value, "")
|
||||
|
||||
@property
|
||||
def active_view(self) -> str:
|
||||
return self._active_view
|
||||
|
||||
@active_view.setter
|
||||
def active_view(self, value: str) -> None:
|
||||
if self._active_view == value:
|
||||
return
|
||||
self._active_view = value
|
||||
self.active_view_changed.emit(value)
|
||||
|
||||
@property
|
||||
def com_connected(self) -> bool:
|
||||
return self._com_connected
|
||||
|
||||
@com_connected.setter
|
||||
def com_connected(self, value: bool) -> None:
|
||||
if self._com_connected == value:
|
||||
return
|
||||
self._com_connected = value
|
||||
self.com_connection_changed.emit(value, "")
|
||||
|
||||
@property
|
||||
def buttons_initialized(self) -> bool:
|
||||
return self._buttons_initialized
|
||||
|
||||
@buttons_initialized.setter
|
||||
def buttons_initialized(self, value: bool) -> None:
|
||||
if self._buttons_initialized == value:
|
||||
return
|
||||
self._buttons_initialized = value
|
||||
self.button_initialization_changed.emit(value, self._button_count)
|
||||
|
||||
def set_button_initialization(self, is_initialized: bool, button_count: int) -> None:
|
||||
"""Единая точка обновления статуса инициализации кнопок."""
|
||||
self._buttons_initialized = is_initialized
|
||||
self._button_count = button_count
|
||||
self.button_initialization_changed.emit(is_initialized, button_count)
|
||||
|
||||
def set_com_connection(self, is_connected: bool, message: str) -> None:
|
||||
"""Единая точка обновления статуса COM-подключения."""
|
||||
self._com_connected = is_connected
|
||||
self.com_connection_changed.emit(is_connected, message)
|
||||
|
||||
def _apply_update(self, task: TicketTask, task_data: Mapping[str, Any]) -> None:
|
||||
"""Применить обновление к уже существующей задаче."""
|
||||
new_state = task_data.get("state")
|
||||
if new_state is not None:
|
||||
try:
|
||||
normalized_state = int(new_state)
|
||||
except (TypeError, ValueError):
|
||||
normalized_state = task.state_code
|
||||
else:
|
||||
normalized_state = task.state_code
|
||||
|
||||
if normalized_state == 4 and task.state_code != 4:
|
||||
task.refused_from_state = task.state_code
|
||||
elif normalized_state != 4 and "location" in task_data:
|
||||
task.location = str(task_data.get("location", task.location))
|
||||
|
||||
task.state_code = normalized_state
|
||||
if "action" in task_data:
|
||||
task.action_text = str(task_data.get("action", task.action_text))
|
||||
if "state_name" in task_data:
|
||||
task.state_name = str(task_data.get("state_name", task.state_name))
|
||||
if "color" in task_data:
|
||||
task.color_hex = _normalize_color(task_data.get("color"))
|
||||
if "created_time" in task_data:
|
||||
task.created_at = _normalize_datetime(task_data.get("created_time")) or task.created_at
|
||||
if "completed_time" in task_data:
|
||||
task.completed_at = _normalize_datetime(task_data.get("completed_time"))
|
||||
if "refused_from_state" in task_data:
|
||||
task.refused_from_state = _normalize_int(
|
||||
task_data.get("refused_from_state"),
|
||||
task.refused_from_state,
|
||||
)
|
||||
if "refusal_reason" in task_data and task_data.get("refusal_reason") is not None:
|
||||
task.refusal_reason = str(task_data.get("refusal_reason", "")).strip()
|
||||
if "assigned_specialist" in task_data and task_data.get("assigned_specialist") is not None:
|
||||
task.assigned_specialist = str(task_data.get("assigned_specialist", ""))
|
||||
if "specialist_photo" in task_data and task_data.get("specialist_photo") is not None:
|
||||
task.specialist_photo = str(task_data.get("specialist_photo", ""))
|
||||
if "diagnostic_report_signed" in task_data and task_data.get("diagnostic_report_signed") is not None:
|
||||
task.diagnostic_report_signed = bool(task_data.get("diagnostic_report_signed"))
|
||||
if "repair_report_signed" in task_data and task_data.get("repair_report_signed") is not None:
|
||||
task.repair_report_signed = bool(task_data.get("repair_report_signed"))
|
||||
if "acceptance_report_signed" in task_data and task_data.get("acceptance_report_signed") is not None:
|
||||
task.acceptance_report_signed = bool(task_data.get("acceptance_report_signed"))
|
||||
if normalized_state == 0 and task.completed_at is None:
|
||||
task.completed_at = datetime.now()
|
||||
|
||||
def _save_and_emit(self, task_id: int) -> None:
|
||||
"""Сохранить состояние и уведомить подписчиков о задаче."""
|
||||
self._persist()
|
||||
snapshot = self.get_task(task_id)
|
||||
if snapshot is not None:
|
||||
self.task_updated.emit(snapshot)
|
||||
|
||||
def _persist(self) -> None:
|
||||
"""Сохранить весь state в репозиторий."""
|
||||
try:
|
||||
self._repository.save_tasks(list(self._tasks.values()))
|
||||
except Exception as exc:
|
||||
log_exception(__name__, "TicketRuntimeState._persist", exc)
|
||||
44
Dispatch_V0.1.1/state/ticket_state_api.py
Normal file
44
Dispatch_V0.1.1/state/ticket_state_api.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# hub/ticket/state/ticket_state_api.py
|
||||
|
||||
"""Публичный state API модуля Ticket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from domain import TicketTaskSnapshot
|
||||
|
||||
|
||||
class TicketStateApi(Protocol):
|
||||
"""Контракт для канонического runtime-state и persistence-контура Ticket."""
|
||||
|
||||
def load(self) -> None:
|
||||
"""Загрузить состояние из репозитория."""
|
||||
|
||||
def list_tasks(self) -> Sequence[TicketTaskSnapshot]:
|
||||
"""Вернуть все известные задачи."""
|
||||
|
||||
def list_active_tasks(self) -> Sequence[TicketTaskSnapshot]:
|
||||
"""Вернуть активные задачи."""
|
||||
|
||||
def list_archived_tasks(self) -> Sequence[TicketTaskSnapshot]:
|
||||
"""Вернуть архивные задачи."""
|
||||
|
||||
def get_task(self, task_id: int) -> TicketTaskSnapshot | None:
|
||||
"""Вернуть задачу по идентификатору."""
|
||||
|
||||
def upsert_task(self, task: TicketTaskSnapshot) -> None:
|
||||
"""Создать или обновить задачу в каноническом состоянии."""
|
||||
|
||||
def update_task(self, task_data: Mapping[str, Any]) -> TicketTaskSnapshot | None:
|
||||
"""Обновить задачу по словарю совместимых полей."""
|
||||
|
||||
def remove_task(self, task_id: int) -> None:
|
||||
"""Удалить задачу из состояния."""
|
||||
|
||||
def sign_report(self, task_id: int, report_type: str) -> None:
|
||||
"""Зафиксировать подписание отчёта."""
|
||||
|
||||
def can_advance_to_confirmation(self, task_id: int) -> bool:
|
||||
"""Проверить готовность задачи к подтверждению."""
|
||||
Reference in New Issue
Block a user