lct-55: ошибка адреса и сообщение в 112

This commit is contained in:
kaifarikman 2026-09-28 00:55:28 +03:00 • committed by gglamer
commit f0c9015f2b
14 changed files with 267 additions and 7 deletions

View file

@ -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"),
]

View file

@ -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

View file

@ -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 (

View file

@ -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,

View file

@ -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("Карточка отсутствует в текущей очереди")

View file

@ -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))),