From f0c9015f2b4a9edad34a6ff73b3966fc111a0d24 Mon Sep 17 00:00:00 2001 From: kaifarikman Date: Mon, 28 Sep 2026 00:55:28 +0300 Subject: [PATCH] =?UTF-8?q?lct-55:=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20112?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/domain/events.py | 18 ++++- backend/app/domain/statuses.py | 2 + backend/app/scenarios/schema.py | 34 ++++++++- backend/app/scoring/export.py | 16 +++++ backend/app/session/dds.py | 22 +++++- backend/app/session/state.py | 7 ++ backend/tests/test_dds_exercise.py | 73 +++++++++++++++++++- backend/tests/test_report_export.py | 27 +++++++- backend/tests/test_session_pause.py | 2 +- frontend/src/features/debrief/Debrief.tsx | 2 + frontend/src/pages/dds/Dds.tsx | 16 +++++ frontend/src/pages/instructor/Instructor.tsx | 3 + frontend/src/shared/types/generated.ts | 12 +++- scenarios/dds-card-address-error.yaml | 40 +++++++++++ 14 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 scenarios/dds-card-address-error.yaml diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 3c3ca7f..f8f2ef8 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -678,6 +678,22 @@ class CardReply(BaseModel): text: str = Field(max_length=2000) +class CardErrorReport112(BaseModel): + """Сообщение диспетчера в 112 после доклада бригады с места.""" + + type: Literal["card.error_report_112"] = "card.error_report_112" + card_id: UUID + text: str = Field(min_length=10, max_length=500) + + @field_validator("text") + @classmethod + def meaningful_text(cls, value: str) -> str: + value = value.strip() + if len(value) < 10: + raise ValueError("Укажите, что сообщено оператору 112") + return value + + class CardNext(BaseModel): type: Literal["card.next"] = "card.next" card_id: UUID @@ -721,7 +737,7 @@ ServerToStation = Annotated[ StationToServer = Annotated[ CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish - | CardReply | CardNext | CardOpen + | CardReply | CardErrorReport112 | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived, Field(discriminator="type"), ] diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 4e08c78..c765fe7 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -288,6 +288,8 @@ class StationSnapshot(BaseModel): phone_reports: list[PhoneReportRecord] = [] phone_lines: list[PhoneLineRecord] = [] phone_pending: PhoneCallPending | None = None + card_error_discovered: bool = False + report_112_text: str | None = None card_id: UUID | None = None card_index: int = 1 card_total: int = 1 diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index a27952c..e4d8a55 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -5,12 +5,15 @@ на старте приложения, а не тихо игнорироваться и всплывать посреди занятия. """ -from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome from app.domain.events import Mood from app.domain.kio import EDITABLE_KIO_FIELDS, KIO from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus +from app.scoring.address import address_matches from app.scoring.taxonomy import METRIC_MAP @@ -174,6 +177,21 @@ FALLBACK_NOTIFY: dict[str, str] = { } +class DdsCardError(Strict): + """Заложенное расхождение готовой карточки с эталоном для занятия ДДС.""" + + field: Literal["address"] + card_value: str = Field(min_length=3) + + @field_validator("card_value") + @classmethod + def nonempty_card_value(cls, value: str) -> str: + value = value.strip() + if len(value) < 3: + raise ValueError("dds_card_error.card_value: укажите неверный адрес карточки") + return value + + class Scenario(Strict): id: str title: str @@ -202,6 +220,7 @@ class Scenario(Strict): # справка и передача в другой регион разбираются в lct-36. outcome: Outcome = Outcome.CARD dds_decision: DdsDecision = DdsDecision() + dds_card_error: DdsCardError | None = None # Готовая КИО, сформированная курсантом и утверждённая преподавателем. # Для системных сценариев поле отсутствует; ДДС использует снимок как @@ -258,6 +277,19 @@ class Scenario(Strict): ) return self + @model_validator(mode="after") + def valid_dds_card_error(self): + error = self.dds_card_error + if error is None: + return self + if not self.ground_truth.address or not self.ground_truth.address.strip(): + raise ValueError("dds_card_error: для ошибки адреса нужен ground_truth.address") + if self.student_card is not None: + raise ValueError("dds_card_error: используйте отдельный учебный сценарий без student_card") + if address_matches(self.ground_truth.address, error.card_value): + raise ValueError("dds_card_error: адрес карточки должен отличаться от эталона") + return self + @model_validator(mode="after") def unique_reference_ids(self): for label, values in ( diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index 4775d0f..bbda0e1 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -99,6 +99,11 @@ def to_csv(report: SessionReport) -> bytes: row("Сверка самооценки", "", label, ", ".join(values)) for number, item in enumerate(report.notes, 1): row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author) + for card_number, card in enumerate(report.card_results, 1): + for action in card.actions: + if action.get("type") == "card.error_report_112": + row("Сообщение в 112", card_number, "Ошибка карточки", + action.get("text", ""), action.get("at", "")) return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8") @@ -259,6 +264,17 @@ def to_pdf(report: SessionReport) -> bytes: for item in report.notes: story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}")) + messages_112 = [ + (number, action) + for number, card in enumerate(report.card_results, 1) + for action in card.actions + if action.get("type") == "card.error_report_112" + ] + if messages_112: + section("Сообщения в 112 об ошибках карточек") + for number, action in messages_112: + story.append(p(f"Карточка {number}, {action.get('at', '')}: {action.get('text', '')}")) + output = io.BytesIO() document = SimpleDocTemplate( output, pagesize=A4, leftMargin=42, rightMargin=42, diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 83d8d20..ad0cfbe 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -195,6 +195,9 @@ def build_card( ) if service: kio = apply_patch(kio, {"dds": service}) + if scenario.dds_card_error is not None: + # Ошибка относится к выданному снимку; эталон сценария остаётся верным. + kio = kio.model_copy(update={"address": scenario.dds_card_error.card_value}) kio.response_status = ResponseStatus.TRANSFERRED timers = SessionTimers(limits=dict(limits)) timers.on_event("dds.dispatch") @@ -401,7 +404,14 @@ def _deliver_card_adjacent(state: "SessionState", card: DdsLiveCard, now: dateti def _finish_phone_call(card: DdsLiveCard) -> list[Any]: call = card.phone_pending assert call is not None - text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки") + error = card.scenario.dds_card_error + if call.phase == "arrived" and error is not None and error.field == "address": + actual = card.scenario.ground_truth.address + text = (f"{call.crew}: прибыли по адресу {actual}. " + f"В карточке указан {card.dispatched_card.address}; " + "адрес не совпадает. Сообщите об ошибке в 112.") + else: + text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки") line = _line(card, "crew", text) report = PhoneReportRecord(service=call.service, crew=call.crew, phase=call.phase, text=text, at=now_utc()) @@ -749,6 +759,16 @@ class DdsDesk(BaseModel): return DeskOutcome() card.reply_text = command.text card.reply_log.append((now_utc(), command.text)) + case "card.error_report_112": + if command.card_id != card.card_id: + return _refused("Сообщение относится не к текущей карточке") + if card.scenario.dds_card_error is None or not any( + report.phase == "arrived" for report in card.phone_reports + ): + return _refused("Сначала получите доклад бригады с места об ошибке карточки") + if any(action == "card.error_report_112" for action, _at, _detail in card.dds_log): + return DeskOutcome() + card.dds_log.append(("card.error_report_112", now_utc(), command.text.strip())) case "card.open": if not dds_lesson or not self.open(command.card_id): return _refused("Карточка отсутствует в текущей очереди") diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 5892ca1..173cf00 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -436,6 +436,13 @@ class SessionState(PersistedSession): phone_reports=list(card.phone_reports) if card is not None else [], phone_lines=list(card.phone_lines) if card is not None else [], phone_pending=card.phone_pending if card is not None else None, + card_error_discovered=(card.scenario.dds_card_error is not None and any( + report.phase == "arrived" for report in card.phone_reports + )) if card is not None else False, + report_112_text=(next( + (detail for action, _at, detail in reversed(card.dds_log) + if action == "card.error_report_112"), None, + ) if card is not None else None), card_id=card.card_id if card is not None else None, card_index=(desk.card_index + 1 if card is not None else min(desk.next_index + 1, len(desk.scenarios))), diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index cef4c06..683d397 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -2,7 +2,7 @@ import asyncio import time -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest @@ -17,6 +17,7 @@ from app.domain.timers import TimerCode from app.main import app from app.scenarios import store from app.session.hub import hub +from app.session.checkpoint import dump_state, load_state from app.session.store import MemorySessionStore @@ -836,6 +837,76 @@ def send_status(station, service, status): read_until(station, "station.state") +def test_crew_discovers_card_address_error_and_dispatcher_reports_it_to_112(client): + session_id, control = start(client, scenario_id="dds-card-address-error") + try: + state = hub.get(session_id) + card = state.desk.active + assert card.dispatched_card.address == "улица Ленина, 16" + assert card.scenario.ground_truth.address == "улица Ленина, 14" + with client.websocket_connect(f"/ws/station/{session_id}") as station: + service = accept_with_crew(station) + station.send_json({ + "type": "card.error_report_112", "card_id": str(card.card_id), + "text": "В карточке неверный адрес", + }) + assert "Сначала получите доклад" in read_until(station, "error")["message"] + complete_phone_call(station, state, "dispatched") + read_until(station, "station.state") + send_status(station, service, "responding") + report = complete_phone_call(station, state, "arrived") + snapshot = read_until(station, "station.state")["snapshot"] + assert "улица Ленина, 14" in report["text"] + assert "улица Ленина, 16" in report["text"] + assert snapshot["card_error_discovered"] is True + assert snapshot["report_112_text"] is None + + message = "В карточке дом 16; бригада на месте установила дом 14." + station.send_json({ + "type": "card.error_report_112", "card_id": str(card.card_id), "text": message, + }) + snapshot = read_until(station, "station.state")["snapshot"] + assert snapshot["report_112_text"] == message + station.send_json({ + "type": "card.error_report_112", "card_id": str(card.card_id), "text": message, + }) + assert sum(action == "card.error_report_112" for action, _, _ in card.dds_log) == 1 + + active = client.get("/api/sessions/active").json() + teacher = next(row for row in active if row["session_id"] == str(session_id)) + assert teacher["dds_snapshot"]["report_112_text"] == message + restored = load_state(dump_state(state), datetime.now(UTC)) + assert restored.station_snapshot().report_112_text == message + station.send_json({"type": "station.finish"}) + read_until(station, "score.ready") + + report = client.get(f"/api/sessions/{session_id}/report").json() + actions = report["card_results"][0]["actions"] + assert any(action["type"] == "card.error_report_112" and action["text"] == message + for action in actions) + assert message in client.get(f"/api/sessions/{session_id}/report.csv").text + finally: + control.__exit__(None, None, None) + + +def test_report_to_112_is_unavailable_without_planted_card_error(client): + session_id, control = start(client) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "card.received") + snapshot = read_until(station, "station.state")["snapshot"] + assert snapshot["card_error_discovered"] is False + station.send_json({ + "type": "card.error_report_112", "card_id": str(state.desk.active.card_id), + "text": "Сообщаю об ошибке карточки в 112", + }) + assert "Сначала получите доклад" in read_until(station, "error")["message"] + assert not state.desk.active.dds_log + finally: + control.__exit__(None, None, None) + + @pytest.mark.parametrize(("reaction", "passed"), [ ("fast", True), ("slow", False), ("missing", False), ("early", True), ]) diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py index 1c56ea8..d4eff3b 100644 --- a/backend/tests/test_report_export.py +++ b/backend/tests/test_report_export.py @@ -16,7 +16,7 @@ from fastapi.testclient import TestClient from app.api.auth import Principal from app.api.http import sessions from app.config import get_settings -from app.domain.events import SessionReport +from app.domain.events import DdsCardReport, SessionReport from app.domain.roles import Role from app.main import app from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf @@ -83,6 +83,31 @@ def test_csv_shows_enabled_report_reaction_limit(): assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows +def test_card_error_message_to_112_is_in_csv_and_pdf(tmp_path): + report = sample_report() + message = "В карточке дом 16; по докладу бригады фактически дом 14." + report.card_results = [DdsCardReport( + card_id=uuid4(), scenario_id="dds-card-address-error", score_auto=100, + reply_text="", metrics=[], findings=[], actions=[{ + "type": "card.error_report_112", "text": message, + "at": "2026-09-21T12:05:00+00:00", + }], + )] + + rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig")))) + assert any(row[0] == "Сообщение в 112" and row[3] == message for row in rows) + + import shutil + import subprocess + + if shutil.which("pdftotext"): + path = tmp_path / "dds-error.pdf" + path.write_bytes(to_pdf(report)) + extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True) + assert "Сообщения в 112 об ошибках карточек" in extracted + assert message in extracted + + def test_pdf_supports_cyrillic_and_spans_pages(tmp_path): data = to_pdf(sample_report(long=True)) assert data.startswith(b"%PDF-") diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py index 8527ce3..e7e011a 100644 --- a/backend/tests/test_session_pause.py +++ b/backend/tests/test_session_pause.py @@ -236,8 +236,8 @@ def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock): clock.advance(60) state.resume() clock.advance(15) - card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято") card.on_event("dds.open") + card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято") metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack") assert metric.passed is True diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 58ad4dc..276a0e6 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -103,6 +103,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {card.address ? ` · ${card.address}` : ""} {card.managed_service &&

Ответственная служба: {card.managed_service}

}

Ответ диспетчера: {card.reply_text || "не внесён"}

+ {(card.actions ?? []).filter((action) => action.type === "card.error_report_112").map((action, actionIndex) => +

Сообщение об ошибке в 112: {String(action.text ?? "")}

)} {card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) &&