Merge branch 'feat/service-catalog'
lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку
This commit is contained in:
commit
8ff51a8955
15 changed files with 1853 additions and 16 deletions
|
|
@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
|
|||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services() -> dict:
|
||||
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
|
||||
|
||||
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
|
||||
курсанту так же безопасно, как группы происшествий.
|
||||
"""
|
||||
data = ekp.catalog()
|
||||
return {"source": data.source, "services": [item.model_dump() for item in data.services]}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from app.domain.events import (
|
|||
TraineeToServer,
|
||||
)
|
||||
from app.dialog.slots import TurnResult
|
||||
from app.domain.kio import PatchRejected, get_field
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_handoff_queue
|
||||
|
|
@ -270,16 +271,37 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
|
||||
case "kio.patch":
|
||||
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||
state.patch_kio(event.fields)
|
||||
old_extra = list(state.kio.notify_extra)
|
||||
try:
|
||||
state.patch_kio(event.fields)
|
||||
except (ValidationError, PatchRejected) as exc:
|
||||
reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
|
||||
# Недопустимое значение — отказ курсанту, а не сбой операции:
|
||||
# сбой закрыл бы занятие на этом узле.
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message=f"Поле карточки не принято: {reason}"[:200],
|
||||
))
|
||||
# Пакет отклонён целиком, а фронт держит его в pending до эха.
|
||||
# Серверное значение с `auto` снимает pending — иначе на экране
|
||||
# останутся правки, которых на сервере нет.
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields={path: get_field(state.kio, path) for path in event.fields},
|
||||
source=PatchSource.AUTO,
|
||||
))
|
||||
return
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields=event.fields, source=PatchSource.OPERATOR,
|
||||
))
|
||||
auto: dict = {}
|
||||
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields={"incident_code": state.kio.incident_code,
|
||||
"notify": list(state.kio.notify)},
|
||||
source=PatchSource.AUTO,
|
||||
))
|
||||
auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
|
||||
# Добавки сервер чистит сам (повторы, службы из notify) — фронту
|
||||
# нужно итоговое значение, а не эхо присланного.
|
||||
if state.kio.notify_extra != event.fields.get("notify_extra", old_extra):
|
||||
auto["notify_extra"] = list(state.kio.notify_extra)
|
||||
if auto:
|
||||
hub.to_trainee(session_id, KioPatchOut(fields=auto, source=PatchSource.AUTO))
|
||||
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
||||
# посреди занятия дороже лишних килобайт.
|
||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ from pathlib import Path
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
DATA = Path(__file__).parent / "ekp.json"
|
||||
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
|
||||
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
|
||||
CATALOG = Path(__file__).parent / "services_catalog.json"
|
||||
|
||||
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
||||
#: не показывается — книга помечает их прямо в первом признаке.
|
||||
|
|
@ -89,6 +92,37 @@ def reference() -> Reference:
|
|||
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class CatalogService(BaseModel):
|
||||
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
organization: str | None = None
|
||||
#: «ДДС», если это явно указано в названии в каталоге.
|
||||
service_level: str | None = None
|
||||
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
|
||||
source_reference: str
|
||||
|
||||
|
||||
class Catalog(BaseModel):
|
||||
source: str
|
||||
transcription: str
|
||||
license: str
|
||||
services: list[CatalogService]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def catalog() -> Catalog:
|
||||
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
|
||||
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
|
||||
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def catalog_names() -> frozenset[str]:
|
||||
return frozenset(service.name for service in catalog().services)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _by_code() -> dict[str, Incident]:
|
||||
return {incident.code: incident for incident in reference().incidents}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ class KIO(BaseModel):
|
|||
# ДДС. `notify` считается из признаков и правится только системой:
|
||||
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
|
||||
notify: list[str] = Field(default_factory=list, description="Список оповещения")
|
||||
#: Службы, добавленные оператором вручную из каталога. Автоматический список
|
||||
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
|
||||
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
|
||||
#: загрузку сохранённых снимков занятий и сданных карточек.
|
||||
notify_extra: list[str] = Field(
|
||||
default_factory=list, max_length=20, description="Дополнительно оповестить"
|
||||
)
|
||||
dds: DDSCode | None = Field(
|
||||
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
|
||||
)
|
||||
|
|
@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
|||
"okato", "address", "street", "building", "entrance", "floor",
|
||||
"intercom_code", "description", "incident_group", "signs",
|
||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||
"evacuation_needed", "dds",
|
||||
"evacuation_needed", "dds", "notify_extra",
|
||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||
"fire.people_inside", "fire.smoke_spread",
|
||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||
|
|
@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
|
|||
return KIO.model_validate(data)
|
||||
|
||||
|
||||
class PatchRejected(ValueError):
|
||||
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
|
||||
|
||||
|
||||
def _check_catalog(names: Any) -> None:
|
||||
"""Только службы каталога: свободный текст в адресатах карточки
|
||||
на АРМ ДДС выглядел бы как настоящая служба."""
|
||||
from app.domain import ekp
|
||||
|
||||
if not isinstance(names, list):
|
||||
return # тип и лимит проверит модель
|
||||
known = ekp.catalog_names()
|
||||
unknown = [name for name in names if name not in known]
|
||||
if unknown:
|
||||
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
|
||||
|
||||
|
||||
def _without_auto_services(card: KIO) -> KIO:
|
||||
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
|
||||
иначе на АРМ ДДС одна служба окажется в двух строках."""
|
||||
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
|
||||
if extra == card.notify_extra:
|
||||
return card
|
||||
return card.model_copy(update={"notify_extra": extra})
|
||||
|
||||
|
||||
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
||||
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
|
||||
|
||||
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
|
||||
Недопустимое значение — `PatchRejected` или `ValidationError`.
|
||||
"""
|
||||
if "notify_extra" in fields:
|
||||
_check_catalog(fields["notify_extra"])
|
||||
data = card.model_dump()
|
||||
for path, value in fields.items():
|
||||
if path in READ_ONLY_FIELDS:
|
||||
|
|
@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
|||
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
|
||||
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
|
||||
touched = MODIFIER_FIELDS & set(fields)
|
||||
return derive_incident(updated) if touched else updated
|
||||
return _without_auto_services(derive_incident(updated) if touched else updated)
|
||||
|
|
|
|||
1470
backend/app/domain/services_catalog.json
Normal file
1470
backend/app/domain/services_catalog.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue