lct-55: ошибка адреса и сообщение в 112
This commit is contained in:
parent
29aca65e8c
commit
f0c9015f2b
14 changed files with 267 additions and 7 deletions
|
|
@ -678,6 +678,22 @@ class CardReply(BaseModel):
|
||||||
text: str = Field(max_length=2000)
|
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):
|
class CardNext(BaseModel):
|
||||||
type: Literal["card.next"] = "card.next"
|
type: Literal["card.next"] = "card.next"
|
||||||
card_id: UUID
|
card_id: UUID
|
||||||
|
|
@ -721,7 +737,7 @@ ServerToStation = Annotated[
|
||||||
StationToServer = Annotated[
|
StationToServer = Annotated[
|
||||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
|
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
|
||||||
| PhoneHangup | PhoneReply | AdjacentDial | StationFinish
|
| PhoneHangup | PhoneReply | AdjacentDial | StationFinish
|
||||||
| CardReply | CardNext | CardOpen
|
| CardReply | CardErrorReport112 | CardNext | CardOpen
|
||||||
| ZoneDecision | CrewDispatched | CrewArrived,
|
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,8 @@ class StationSnapshot(BaseModel):
|
||||||
phone_reports: list[PhoneReportRecord] = []
|
phone_reports: list[PhoneReportRecord] = []
|
||||||
phone_lines: list[PhoneLineRecord] = []
|
phone_lines: list[PhoneLineRecord] = []
|
||||||
phone_pending: PhoneCallPending | None = None
|
phone_pending: PhoneCallPending | None = None
|
||||||
|
card_error_discovered: bool = False
|
||||||
|
report_112_text: str | None = None
|
||||||
card_id: UUID | None = None
|
card_id: UUID | None = None
|
||||||
card_index: int = 1
|
card_index: int = 1
|
||||||
card_total: int = 1
|
card_total: int = 1
|
||||||
|
|
|
||||||
|
|
@ -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.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||||
from app.domain.events import Mood
|
from app.domain.events import Mood
|
||||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
||||||
from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus
|
from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus
|
||||||
|
from app.scoring.address import address_matches
|
||||||
from app.scoring.taxonomy import METRIC_MAP
|
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):
|
class Scenario(Strict):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -202,6 +220,7 @@ class Scenario(Strict):
|
||||||
# справка и передача в другой регион разбираются в lct-36.
|
# справка и передача в другой регион разбираются в lct-36.
|
||||||
outcome: Outcome = Outcome.CARD
|
outcome: Outcome = Outcome.CARD
|
||||||
dds_decision: DdsDecision = DdsDecision()
|
dds_decision: DdsDecision = DdsDecision()
|
||||||
|
dds_card_error: DdsCardError | None = None
|
||||||
|
|
||||||
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
|
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
|
||||||
# Для системных сценариев поле отсутствует; ДДС использует снимок как
|
# Для системных сценариев поле отсутствует; ДДС использует снимок как
|
||||||
|
|
@ -258,6 +277,19 @@ class Scenario(Strict):
|
||||||
)
|
)
|
||||||
return self
|
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")
|
@model_validator(mode="after")
|
||||||
def unique_reference_ids(self):
|
def unique_reference_ids(self):
|
||||||
for label, values in (
|
for label, values in (
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,11 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Сверка самооценки", "", label, ", ".join(values))
|
row("Сверка самооценки", "", label, ", ".join(values))
|
||||||
for number, item in enumerate(report.notes, 1):
|
for number, item in enumerate(report.notes, 1):
|
||||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
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")
|
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:
|
for item in report.notes:
|
||||||
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
|
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()
|
output = io.BytesIO()
|
||||||
document = SimpleDocTemplate(
|
document = SimpleDocTemplate(
|
||||||
output, pagesize=A4, leftMargin=42, rightMargin=42,
|
output, pagesize=A4, leftMargin=42, rightMargin=42,
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,9 @@ def build_card(
|
||||||
)
|
)
|
||||||
if service:
|
if service:
|
||||||
kio = apply_patch(kio, {"dds": 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
|
kio.response_status = ResponseStatus.TRANSFERRED
|
||||||
timers = SessionTimers(limits=dict(limits))
|
timers = SessionTimers(limits=dict(limits))
|
||||||
timers.on_event("dds.dispatch")
|
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]:
|
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
|
||||||
call = card.phone_pending
|
call = card.phone_pending
|
||||||
assert call is not None
|
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)
|
line = _line(card, "crew", text)
|
||||||
report = PhoneReportRecord(service=call.service, crew=call.crew,
|
report = PhoneReportRecord(service=call.service, crew=call.crew,
|
||||||
phase=call.phase, text=text, at=now_utc())
|
phase=call.phase, text=text, at=now_utc())
|
||||||
|
|
@ -749,6 +759,16 @@ class DdsDesk(BaseModel):
|
||||||
return DeskOutcome()
|
return DeskOutcome()
|
||||||
card.reply_text = command.text
|
card.reply_text = command.text
|
||||||
card.reply_log.append((now_utc(), 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":
|
case "card.open":
|
||||||
if not dds_lesson or not self.open(command.card_id):
|
if not dds_lesson or not self.open(command.card_id):
|
||||||
return _refused("Карточка отсутствует в текущей очереди")
|
return _refused("Карточка отсутствует в текущей очереди")
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,13 @@ class SessionState(PersistedSession):
|
||||||
phone_reports=list(card.phone_reports) if card is not None else [],
|
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_lines=list(card.phone_lines) if card is not None else [],
|
||||||
phone_pending=card.phone_pending if card is not None else None,
|
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_id=card.card_id if card is not None else None,
|
||||||
card_index=(desk.card_index + 1 if card is not None
|
card_index=(desk.card_index + 1 if card is not None
|
||||||
else min(desk.next_index + 1, len(desk.scenarios))),
|
else min(desk.next_index + 1, len(desk.scenarios))),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -17,6 +17,7 @@ from app.domain.timers import TimerCode
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.store import MemorySessionStore
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -836,6 +837,76 @@ def send_status(station, service, status):
|
||||||
read_until(station, "station.state")
|
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"), [
|
@pytest.mark.parametrize(("reaction", "passed"), [
|
||||||
("fast", True), ("slow", False), ("missing", False), ("early", True),
|
("fast", True), ("slow", False), ("missing", False), ("early", True),
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
|
||||||
from app.api.auth import Principal
|
from app.api.auth import Principal
|
||||||
from app.api.http import sessions
|
from app.api.http import sessions
|
||||||
from app.config import get_settings
|
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.domain.roles import Role
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
|
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
|
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):
|
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||||
data = to_pdf(sample_report(long=True))
|
data = to_pdf(sample_report(long=True))
|
||||||
assert data.startswith(b"%PDF-")
|
assert data.startswith(b"%PDF-")
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,8 @@ def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
|
||||||
clock.advance(60)
|
clock.advance(60)
|
||||||
state.resume()
|
state.resume()
|
||||||
clock.advance(15)
|
clock.advance(15)
|
||||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
|
|
||||||
card.on_event("dds.open")
|
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")
|
metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
|
||||||
assert metric.passed is True
|
assert metric.passed is True
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{card.address ? ` · ${card.address}` : ""}</summary>
|
{card.address ? ` · ${card.address}` : ""}</summary>
|
||||||
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
|
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
|
||||||
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
|
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
|
||||||
|
{(card.actions ?? []).filter((action) => action.type === "card.error_report_112").map((action, actionIndex) =>
|
||||||
|
<p key={`report-112-${actionIndex}`}><strong>Сообщение об ошибке в 112:</strong> {String(action.text ?? "")}</p>)}
|
||||||
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
|
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
|
||||||
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
|
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
|
||||||
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
|
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ export function Dds() {
|
||||||
const [crewReplyText, setCrewReplyText] = useState("");
|
const [crewReplyText, setCrewReplyText] = useState("");
|
||||||
const [adjacentTarget, setAdjacentTarget] = useState("");
|
const [adjacentTarget, setAdjacentTarget] = useState("");
|
||||||
const [adjacentText, setAdjacentText] = useState("");
|
const [adjacentText, setAdjacentText] = useState("");
|
||||||
|
const [report112Text, setReport112Text] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
@ -295,6 +296,7 @@ export function Dds() {
|
||||||
setCrewReplyText("");
|
setCrewReplyText("");
|
||||||
setAdjacentTarget("");
|
setAdjacentTarget("");
|
||||||
setAdjacentText("");
|
setAdjacentText("");
|
||||||
|
setReport112Text("");
|
||||||
setZone(null);
|
setZone(null);
|
||||||
setScoreReady(false);
|
setScoreReady(false);
|
||||||
setView(openCardAfterSwitch.current ? "card" : "registry");
|
setView(openCardAfterSwitch.current ? "card" : "registry");
|
||||||
|
|
@ -719,6 +721,20 @@ export function Dds() {
|
||||||
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
|
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
|
||||||
</div>}
|
</div>}
|
||||||
</>}
|
</>}
|
||||||
|
{station?.card_error_discovered && <div className="dds-phone-form">
|
||||||
|
<h3>Сообщение об ошибке карточки в 112</h3>
|
||||||
|
{station.report_112_text ? <p>Сообщено в 112: {station.report_112_text}</p> : <>
|
||||||
|
<p>После доклада бригады сообщите оператору 112, что указано в карточке и что установлено на месте.</p>
|
||||||
|
<label htmlFor="dds-report-112">Содержание сообщения</label>
|
||||||
|
<textarea id="dds-report-112" rows={3} value={report112Text}
|
||||||
|
disabled={!isConnected || ended || paused}
|
||||||
|
onChange={(event) => setReport112Text(event.target.value)} />
|
||||||
|
<button type="button" disabled={!cardId || !isConnected || ended || paused || report112Text.trim().length < 10}
|
||||||
|
onClick={() => send({ type: "card.error_report_112", card_id: cardId, text: report112Text })}>
|
||||||
|
Зафиксировать сообщение в 112
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
</div>}
|
||||||
</section>}
|
</section>}
|
||||||
{pane === "reply" && <section className="dds-panel dds-reply">
|
{pane === "reply" && <section className="dds-panel dds-reply">
|
||||||
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,9 @@ export function Instructor() {
|
||||||
<small>{report.text}</small>
|
<small>{report.text}</small>
|
||||||
</p>)}
|
</p>)}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
|
{item.dds_snapshot.report_112_text && <p className="active-session-log">
|
||||||
|
<strong>Сообщено в 112 об ошибке карточки:</strong> {item.dds_snapshot.report_112_text}
|
||||||
|
</p>}
|
||||||
</details>}</td>
|
</details>}</td>
|
||||||
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
||||||
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ export interface CardBriefing {
|
||||||
handoff_to_dds?: boolean;
|
handoff_to_dds?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сообщение диспетчера в 112 после доклада бригады с места. */
|
||||||
|
export interface CardErrorReport112 {
|
||||||
|
type: "card.error_report_112";
|
||||||
|
card_id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardNext {
|
export interface CardNext {
|
||||||
type: "card.next";
|
type: "card.next";
|
||||||
card_id: string;
|
card_id: string;
|
||||||
|
|
@ -719,6 +726,8 @@ export interface StationSnapshot {
|
||||||
phone_reports?: Array<PhoneReportRecord>;
|
phone_reports?: Array<PhoneReportRecord>;
|
||||||
phone_lines?: Array<PhoneLineRecord>;
|
phone_lines?: Array<PhoneLineRecord>;
|
||||||
phone_pending?: PhoneCallPending | null;
|
phone_pending?: PhoneCallPending | null;
|
||||||
|
card_error_discovered?: boolean;
|
||||||
|
report_112_text?: string | null;
|
||||||
card_id?: string | null;
|
card_id?: string | null;
|
||||||
card_index?: number;
|
card_index?: number;
|
||||||
card_total?: number;
|
card_total?: number;
|
||||||
|
|
@ -847,7 +856,7 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
|
||||||
|
|
||||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
||||||
|
|
||||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardErrorReport112 | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||||
|
|
||||||
export type EventName =
|
export type EventName =
|
||||||
"adjacent.dial"
|
"adjacent.dial"
|
||||||
|
|
@ -864,6 +873,7 @@ export type EventName =
|
||||||
| "card.ack"
|
| "card.ack"
|
||||||
| "card.bounce"
|
| "card.bounce"
|
||||||
| "card.briefing"
|
| "card.briefing"
|
||||||
|
| "card.error_report_112"
|
||||||
| "card.next"
|
| "card.next"
|
||||||
| "card.open"
|
| "card.open"
|
||||||
| "card.received"
|
| "card.received"
|
||||||
|
|
|
||||||
40
scenarios/dds-card-address-error.yaml
Normal file
40
scenarios/dds-card-address-error.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Учебная ошибка уже переданной КИО: бригада обнаруживает верный адрес на месте.
|
||||||
|
# Курсанту ДДС нужно сообщить расхождение оператору 112; автооценки этого шага нет.
|
||||||
|
id: dds-card-address-error
|
||||||
|
title: "Ошибка адреса в готовой карточке пожара"
|
||||||
|
type: fire
|
||||||
|
level: L2
|
||||||
|
topics: [fires]
|
||||||
|
modes: [training]
|
||||||
|
signs: [жилой дом, балкон, открытое пламя]
|
||||||
|
|
||||||
|
persona:
|
||||||
|
base: worried
|
||||||
|
arc:
|
||||||
|
- { stage: registration, mood: worried }
|
||||||
|
|
||||||
|
first_line: "На балконе пожар, приезжайте на улицу Ленина, дом 14."
|
||||||
|
|
||||||
|
facts:
|
||||||
|
- id: f_address
|
||||||
|
value: "улица Ленина, 14, квартира 47"
|
||||||
|
reveal_on: { question: q_address }
|
||||||
|
- id: f_what_burns
|
||||||
|
value: "горит балкон, жильцы выходят из квартиры"
|
||||||
|
reveal_on: { question: q_what_burns }
|
||||||
|
|
||||||
|
checklist:
|
||||||
|
- { id: q_address, fact: f_address, question: "Уточните адрес происшествия" }
|
||||||
|
- { id: q_what_burns, fact: f_what_burns, question: "Что именно горит?" }
|
||||||
|
|
||||||
|
required_fields: [address, incident_type, description]
|
||||||
|
ground_truth:
|
||||||
|
address: "улица Ленина, 14"
|
||||||
|
victims: 0
|
||||||
|
|
||||||
|
dds_card_error:
|
||||||
|
field: address
|
||||||
|
card_value: "улица Ленина, 16"
|
||||||
|
|
||||||
|
tree:
|
||||||
|
pregenerated: false
|
||||||
Loading…
Reference in a new issue