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,9 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/__init__.py
"""Экран деталей и действия Ticket."""
from .task_details_actions import TaskDetailsActions
from .task_details_dialog import TaskDetailsDialog
__all__ = ["TaskDetailsActions", "TaskDetailsDialog"]

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_actions.py
"""Отдельный модуль действий экрана деталей Ticket."""
from __future__ import annotations
from application.ticket_application_api import TicketApplicationApi
from domain import TicketDocumentSnapshot, TicketTaskSnapshot
from ui.dialogs import (
AcceptanceDialog,
DiagnosticReportDialog,
RepairReportDialog,
SpecialistDialog,
TaskRefusalDialog,
)
from ui.ticket_message_dialog import TicketMessageDialog
class TaskDetailsActions:
"""Обёртка над application-командами для details-экрана."""
def __init__(self, application: TicketApplicationApi):
self._application = application
def assign_specialist(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
dialog = SpecialistDialog(
specialists=self._application.list_specialists(),
parent=parent,
)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
specialist_name = dialog.selected_specialist.strip()
if not specialist_name:
self._show_warning(parent, "Имя специалиста не указано.")
return None
snapshot = self._application.assign_specialist(task.task_id, specialist_name)
if snapshot is None:
self._show_warning(parent, "Не удалось назначить специалиста.")
return snapshot
def sign_diagnostic(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = DiagnosticReportDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_diagnostic_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def sign_repair(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = RepairReportDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_repair_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def sign_acceptance(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = AcceptanceDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_acceptance_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def archive_task(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
answer = TicketMessageDialog.ask_confirmation(
parent=parent,
title="Архивация задачи",
message=f"Переместить задачу #{task.sequence_number or task.task_id} в архив?",
accept_text="В архив",
reject_text="Отмена",
)
if not answer:
return None
snapshot = self._application.archive_task(task.task_id)
if snapshot is None:
self._show_warning(parent, "Не удалось переместить задачу в архив.")
return snapshot
def refuse_task(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
dialog = TaskRefusalDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
refusal_reason = dialog.refusal_reason
if not refusal_reason:
self._show_warning(parent, "Причина отказа не указана.")
return None
snapshot = self._application.refuse_task(task.task_id, refusal_reason)
if snapshot is None:
self._show_warning(parent, "Не удалось перевести задачу в отказ.")
return snapshot
@staticmethod
def _show_warning(parent, text: str) -> None:
TicketMessageDialog.show_warning(parent, "Ticket", text)

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_dialog.py
"""Dialog-экран подробностей задачи Ticket в ecco-геометрии."""
from __future__ import annotations
from gui.components import Button, Dialog, Label
from gui.containers import HContainer, VContainer
from application.ticket_application_api import TicketApplicationApi
from domain import TicketTaskSnapshot
from ui.cards.task_card_pixmap_factory import (
build_placeholder_avatar_pixmap,
load_avatar_pixmap,
)
from .task_details_actions import TaskDetailsActions
from .task_details_view_data import (
build_employee_view_data,
build_task_stage_rows,
build_task_summary_rows,
can_archive_task,
can_refuse_task,
)
from .task_stage_action_row import TaskStageActionRow
class TaskDetailsDialog(Dialog):
"""Диалог подробностей задачи и доступных действий по этапам."""
_SUMMARY_TITLES = (
"Учреждение",
"Оборудование",
"Кабинет",
"Назначение",
)
_STAGE_ORDER = (
"specialist",
"diagnostic",
"repair",
"acceptance",
)
def __init__(
self,
application: TicketApplicationApi,
task_id: int,
parent=None,
):
self._application = application
self._task_id = task_id
self._task: TicketTaskSnapshot | None = None
self._actions = TaskDetailsActions(application)
self._summary_value_labels: dict[str, Label] = {}
self._stage_rows: dict[str, TaskStageActionRow] = {}
self._employee_avatar_label: Label | None = None
self._employee_name_label: Label | None = None
self._employee_role_label: Label | None = None
self._refuse_button: Button | None = None
super().__init__(
title="Подробности",
width=360,
height=600,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
self._reload_task()
def _setup_ui(self) -> None:
# Root-контейнер окна: раскладывает summary, этапы, сотрудника и footer по вертикали.
root = VContainer(margin=[24, 20, 24, 20], spacing=20)
self.add_widget(root)
root.add_widget(self._build_summary_section())
root.add_widget(Label("", style="TICKET_DETAILS_DIVIDER"))
root.add_widget(self._build_stages_section())
root.add_widget(self._build_employee_section())
root.add_stretch()
root.add_widget(self._build_footer_section())
def _build_summary_section(self) -> VContainer:
# Summary-section: верхняя сводка по задаче с основными идентификационными полями.
summary = VContainer(spacing=12, content_fit=True)
for title in self._SUMMARY_TITLES:
summary.add_widget(self._build_summary_row(title))
return summary
def _build_summary_row(self, title: str) -> HContainer:
# Summary-row: одна горизонтальная строка конкретного поля в блоке сводки.
row = HContainer(spacing=10, content_fit=True)
title_label = Label(
f"{title}:",
alignment="left",
style="TICKET_DETAILS_SUMMARY_TITLE",
)
value_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_SUMMARY_VALUE",
)
self._summary_value_labels[title] = value_label
row.add_widget(title_label)
row.add_widget_with_stretch(value_label, 1)
return row
def _build_stages_section(self) -> VContainer:
# Stages-section: блок со списком шагов выполнения и доступных действий по ним.
section = VContainer(spacing=12, content_fit=True)
section.add_widget(
Label(
"Этапы выполнения заявки",
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
)
# Stage-list: стек интерактивных строк этапов внутри секции stages.
stage_list = VContainer(spacing=12, content_fit=True)
for stage_key in self._STAGE_ORDER:
stage_row = TaskStageActionRow(stage_key)
stage_row.clicked.connect(self._on_stage_clicked)
self._stage_rows[stage_key] = stage_row
stage_list.add_widget(stage_row)
section.add_widget(stage_list)
return section
def _build_employee_section(self) -> VContainer:
# Employee-section: отдельный блок ответственного сотрудника и его служебной информации.
section = VContainer(spacing=12, content_fit=True)
section.add_widget(
Label(
"Ответственный сотрудник",
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
)
# Employee-row: горизонтальная строка карточки сотрудника с аватаром и текстом.
employee_row = HContainer(spacing=16, content_fit=True)
self._employee_avatar_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE")
self._employee_avatar_label.set_fixed_size(64, 64)
self._employee_name_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_EMPLOYEE_NAME",
)
self._employee_role_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_EMPLOYEE_ROLE",
)
# Info-column: вертикальный столбец имени и должности рядом с аватаром.
info_column = VContainer(spacing=2, content_fit=True)
info_column.add_widget(self._employee_name_label)
info_column.add_widget(self._employee_role_label)
employee_row.add_widget(self._employee_avatar_label)
employee_row.add_widget_with_stretch(info_column, 1)
section.add_widget(employee_row)
return section
def _build_footer_section(self) -> HContainer:
# Footer-row: нижняя линия действий с центрированными кнопками.
footer = HContainer(spacing=12, content_fit=True)
self._refuse_button = Button(
"Отказать в обслуживании",
style="TICKET_DETAILS_REFUSE_BUTTON",
content_fit=True,
)
self._archive_button = Button(
"В архив",
style="TICKET_DETAILS_REFUSE_BUTTON",
content_fit=True,
)
footer.add_stretch()
footer.add_widget(self._refuse_button)
footer.add_widget(self._archive_button)
footer.add_stretch()
return footer
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._on_task_updated)
self._application.task_removed.connect(self._on_task_removed)
if self._refuse_button is not None:
self._refuse_button.clicked.connect(self._on_refuse_clicked)
if self._archive_button is not None:
self._archive_button.clicked.connect(self._on_archive_clicked)
def _reload_task(self) -> None:
task = self._application.get_task(self._task_id)
if task is None:
self.reject()
return
self._task = task
self._update_view(task)
def _update_view(self, task: TicketTaskSnapshot) -> None:
for title, value in build_task_summary_rows(task):
label = self._summary_value_labels.get(title)
if label is not None:
label.set_text(value)
label.set_tooltip(value)
for stage_view in build_task_stage_rows(task):
stage_row = self._stage_rows.get(stage_view.key)
if stage_row is not None:
stage_row.configure(
text=stage_view.text,
icon_path=stage_view.icon_path,
emphasized=stage_view.emphasized,
clickable=stage_view.clickable,
)
self._update_employee_section(task)
if self._refuse_button is not None:
self._refuse_button.set_visible(can_refuse_task(task))
if self._archive_button is not None:
self._archive_button.set_visible(can_archive_task(task))
def _update_employee_section(self, task: TicketTaskSnapshot) -> None:
employee_data = build_employee_view_data(task)
if self._employee_name_label is not None:
self._employee_name_label.set_text(employee_data["name"])
if self._employee_role_label is not None:
role_text = employee_data["position"].strip()
self._employee_role_label.set_text(role_text)
self._employee_role_label.set_visible(bool(role_text))
if self._employee_avatar_label is None:
return
avatar_pixmap = load_avatar_pixmap(
employee_data["photo_path"],
64,
64,
padding=2,
)
if avatar_pixmap is None:
avatar_pixmap = build_placeholder_avatar_pixmap(64)
self._employee_avatar_label.set_pixmap(avatar_pixmap)
def _on_task_updated(self, task: TicketTaskSnapshot) -> None:
if task.task_id != self._task_id:
return
self._task = task
self._update_view(task)
def _on_task_removed(self, task_id: int) -> None:
if task_id == self._task_id:
self.reject()
def _on_stage_clicked(self, stage_key: str) -> None:
if self._task is None:
return
if stage_key == "specialist":
self._actions.assign_specialist(self._task, self)
return
if stage_key == "diagnostic":
self._actions.sign_diagnostic(self._task, self)
return
if stage_key == "repair":
self._actions.sign_repair(self._task, self)
return
if stage_key == "acceptance":
self._actions.sign_acceptance(self._task, self)
def _on_refuse_clicked(self) -> None:
if self._task is None:
return
snapshot = self._actions.refuse_task(self._task, self)
if snapshot is not None:
self.accept()
def _on_archive_clicked(self) -> None:
if self._task is None:
return
snapshot = self._actions.archive_task(self._task, self)
if snapshot is not None:
self.accept()

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_view_data.py
"""Форматирование данных для нового dialog-экрана подробностей Ticket."""
from __future__ import annotations
from dataclasses import dataclass
from domain import TicketTaskSnapshot, parse_location_parts
from domain.ticket_constants import STATE_ARCHIVED, STATE_COMPLETED, STATE_REFUSED
from ui.task_view_formatters import (
build_specialist_card_info,
build_specialist_photo_path,
build_stage_icon_path,
can_assign_specialist,
can_sign_acceptance,
can_sign_diagnostic,
can_sign_repair,
)
@dataclass(frozen=True, slots=True)
class TaskStageRowData:
"""View-data одной строки этапа выполнения заявки."""
key: str
text: str
icon_path: str
emphasized: bool
clickable: bool
def build_task_summary_rows(task: TicketTaskSnapshot) -> tuple[tuple[str, str], ...]:
institution, room, device = parse_location_parts(task.location or "")
return (
("Учреждение", institution or "Локация не указана"),
("Оборудование", device or "Аппарат не указан"),
("Кабинет", room or "Кабинет не указан"),
("Назначение", build_task_status_text(task)),
)
def build_task_status_text(task: TicketTaskSnapshot) -> str:
specialist_info = build_specialist_card_info(task.assigned_specialist)
short_name = specialist_info["short_name"].strip()
if not short_name:
return "Специалист не назначен"
return short_name
def build_employee_view_data(task: TicketTaskSnapshot) -> dict[str, str]:
specialist_info = build_specialist_card_info(task.assigned_specialist)
short_name = specialist_info["short_name"].strip()
position = specialist_info["position"].strip()
return {
"name": short_name or "Специалист не назначен",
"position": position if short_name else "Ожидает назначения",
"photo_path": build_specialist_photo_path(
task.assigned_specialist,
task.specialist_photo,
),
}
def build_task_stage_rows(task: TicketTaskSnapshot) -> tuple[TaskStageRowData, ...]:
return (
TaskStageRowData(
key="specialist",
text="Специалист назначен" if task.assigned_specialist.strip() else "Назначить специалиста",
icon_path=build_stage_icon_path("specialist", True),
emphasized=bool(task.assigned_specialist.strip()),
clickable=can_assign_specialist(task),
),
TaskStageRowData(
key="diagnostic",
text=(
"Отчёт диагностики составлен"
if task.diagnostic_report_signed
else "Составить отчёт диагностики"
),
icon_path=build_stage_icon_path("diagnostic", True),
emphasized=bool(task.diagnostic_report_signed),
clickable=can_sign_diagnostic(task),
),
TaskStageRowData(
key="repair",
text=(
"Отчёт по ремонту составлен"
if task.repair_report_signed
else "Составить отчёт по ремонту"
),
icon_path=build_stage_icon_path("repair", True),
emphasized=bool(task.repair_report_signed),
clickable=can_sign_repair(task),
),
TaskStageRowData(
key="acceptance",
text=(
"Акт приёмки работ подписан"
if task.acceptance_report_signed
else "Составить акт приёмки работ"
),
icon_path=build_stage_icon_path("acceptance", True),
emphasized=bool(task.acceptance_report_signed),
clickable=can_sign_acceptance(task),
),
)
def can_refuse_task(task: TicketTaskSnapshot) -> bool:
return task.state_code not in {STATE_COMPLETED, STATE_REFUSED, STATE_ARCHIVED}
def can_archive_task(task: TicketTaskSnapshot) -> bool:
return task.state_code in {STATE_COMPLETED, STATE_REFUSED}

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_stage_action_row.py
"""Clickable task stage row for Ticket details."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QMouseEvent
from gui.components import Label
from gui.containers import HContainer
from gui.theme_bus import theme_bus
from ui.cards.task_card_pixmap_factory import load_tinted_icon_pixmap
class TaskStageActionRow(HContainer):
"""Row with icon, text and optional click action."""
clicked = Signal(str)
def __init__(self, stage_key: str, parent=None):
super().__init__(
margin=[4, 2, 4, 2],
spacing=12,
content_fit=True,
parent=parent,
style="TICKET_DETAILS_STAGE_ROW",
active_style="TICKET_DETAILS_STAGE_ROW_ACTIVE",
is_active=False,
)
self._stage_key = stage_key
self._icon_path = ""
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self._is_clickable = False
self._is_highlighted = False
self._icon_label: Label | None = None
self._text_label: Label | None = None
self._setup_ui()
theme_bus.theme_changed.connect(self.set_theme)
def _setup_ui(self) -> None:
# Root-row этапа: держит иконку, текст этапа и свободное место для выравнивания строки.
self._icon_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE")
self._icon_label.set_fixed_size(22, 22)
self._text_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_STAGE_TEXT_INACTIVE",
active_style="TICKET_DETAILS_STAGE_TEXT_ACTIVE",
is_active=False,
)
self.add_widget(self._icon_label)
self.add_widget(self._text_label)
self.add_stretch()
def configure(
self,
text: str,
icon_path: str,
emphasized: bool,
clickable: bool,
) -> None:
self._icon_path = icon_path
self._is_clickable = bool(clickable)
self._is_highlighted = bool(emphasized) or self._is_clickable
if self._text_label is not None:
self._text_label.set_text(text)
self._text_label.style(is_active=self._is_highlighted)
self.style(is_active=self._is_clickable)
self.setCursor(
Qt.CursorShape.PointingHandCursor
if self._is_clickable
else Qt.CursorShape.ArrowCursor
)
self._refresh_icon()
@Slot(str)
def set_theme(self, theme: str) -> None:
normalized_theme = (theme or "").strip().lower()
if normalized_theme in {"dark", "light"}:
self._theme = normalized_theme
super().set_theme(theme)
self._refresh_icon()
def mousePressEvent(self, event: QMouseEvent) -> None:
if self._is_clickable and event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._stage_key)
super().mousePressEvent(event)
def _refresh_icon(self) -> None:
if self._icon_label is None or not self._icon_path:
return
icon_width = self._icon_label.width() or 22
icon_height = self._icon_label.height() or 22
pixmap = load_tinted_icon_pixmap(
self._icon_path,
icon_width,
icon_height,
self._resolve_icon_color(),
padding=2,
)
if pixmap is not None:
self._icon_label.set_pixmap(pixmap)
def _resolve_icon_color(self) -> str:
if self._is_highlighted:
return "#F5F7FA" if self._theme == "dark" else "#172B4D"
return "#8C9BAB" if self._theme == "dark" else "#6B778C"