fix: отказ kio.patch возвращает серверные значения, каталог служб проверяется на входе, без дублей с notify

This commit is contained in:
GGlamer 2026-09-27 22:23:15 +03:00
commit f9d2c2cc77
5 changed files with 144 additions and 35 deletions

View file

@ -39,6 +39,7 @@ from app.domain.events import (
TraineeToServer, TraineeToServer,
) )
from app.dialog.slots import TurnResult from app.dialog.slots import TurnResult
from app.domain.kio import PatchRejected, get_field
from app.domain.roles import Role from app.domain.roles import Role
from app.scenarios import store from app.scenarios import store
from app.session.dds import prepare_handoff_queue from app.session.dds import prepare_handoff_queue
@ -270,25 +271,37 @@ async def _handle(session_id: UUID, state, event) -> None:
case "kio.patch": case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify) old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
old_extra = list(state.kio.notify_extra)
try: try:
state.patch_kio(event.fields) state.patch_kio(event.fields)
except ValidationError as exc: except (ValidationError, PatchRejected) as exc:
reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
# Недопустимое значение — отказ курсанту, а не сбой операции: # Недопустимое значение — отказ курсанту, а не сбой операции:
# сбой закрыл бы занятие на этом узле. # сбой закрыл бы занятие на этом узле.
hub.to_trainee(session_id, ErrorEvent( hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, code=ErrorKind.UNSUPPORTED_EVENT,
message=f"Поле карточки не принято: {exc.errors()[0].get('msg', '')}"[:200], 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 return
hub.to_trainee(session_id, KioPatchOut( hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR, fields=event.fields, source=PatchSource.OPERATOR,
)) ))
auto: dict = {}
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify): if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
hub.to_trainee(session_id, KioPatchOut( auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
fields={"incident_code": state.kio.incident_code, # Добавки сервер чистит сам (повторы, службы из notify) — фронту
"notify": list(state.kio.notify)}, # нужно итоговое значение, а не эхо присланного.
source=PatchSource.AUTO, 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)) hub.to_observers(session_id, KioState(kio=state.kio))

View file

@ -10,7 +10,7 @@ from enum import StrEnum
from typing import Any from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from app.domain.classifiers import DDSCode, IncidentType from app.domain.classifiers import DDSCode, IncidentType
@ -119,7 +119,9 @@ class KIO(BaseModel):
# в боевом АРМ оператор может добавить службу вручную, но не удалить. # в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения") notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список #: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят. #: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
#: загрузку сохранённых снимков занятий и сданных карточек.
notify_extra: list[str] = Field( notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить" default_factory=list, max_length=20, description="Дополнительно оповестить"
) )
@ -135,19 +137,6 @@ class KIO(BaseModel):
medical: MedicalDetails | None = None medical: MedicalDetails | None = None
utility: UtilityDetails | None = None utility: UtilityDetails | None = None
@field_validator("notify_extra")
@classmethod
def known_catalog_services(cls, names: list[str]) -> list[str]:
"""Только службы каталога, без повторов: свободный текст в адресатах
карточки на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise ValueError(f"нет в каталоге служб: {', '.join(unknown[:3])}")
return list(dict.fromkeys(names))
#: Поля, которые курсант не редактирует: их проставляет система. #: Поля, которые курсант не редактирует: их проставляет система.
READ_ONLY_FIELDS: frozenset[str] = frozenset( READ_ONLY_FIELDS: frozenset[str] = frozenset(
@ -241,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
return KIO.model_validate(data) 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: def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются. """Применить дельту `kio.patch`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}. Вложенные поля приходят плоским путём: {"fire.floors": 5}.
Недопустимое значение — `PatchRejected` или `ValidationError`.
""" """
if "notify_extra" in fields:
_check_catalog(fields["notify_extra"])
data = card.model_dump() data = card.model_dump()
for path, value in fields.items(): for path, value in fields.items():
if path in READ_ONLY_FIELDS: if path in READ_ONLY_FIELDS:
@ -261,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую, # Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу. # газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields) touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated return _without_auto_services(derive_incident(updated) if touched else updated)

View file

@ -15,8 +15,8 @@ from pydantic import ValidationError
from app.api.ws import call as call_ws from app.api.ws import call as call_ws
from app.domain import ekp from app.domain import ekp
from app.domain.events import ErrorEvent, KioPatchIn, SessionMode from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, apply_patch from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
from app.main import app from app.main import app
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.scoring.card import evaluate_card from app.scoring.card import evaluate_card
@ -63,8 +63,27 @@ def test_operator_adds_catalog_services_without_changing_the_notify_list():
assert updated.notify_extra == ["ФСБ", "ЦЭМП"] assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит" assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
with pytest.raises(ValidationError): with pytest.raises(PatchRejected):
apply_patch(card, {"notify_extra": ["Служба, которой нет"]}) apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
with pytest.raises(ValidationError):
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
def test_service_already_in_notify_is_not_added_twice():
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
card = KIO(notify=["ЦЭМП"])
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
assert updated.notify_extra == ["ФСБ"]
def test_saved_card_with_renamed_service_still_loads():
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
не должна ломать восстановление снимков и сданных карточек."""
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
assert card.notify_extra == ["Прежнее название службы"]
def test_added_services_do_not_change_the_score_against_the_reference(): def test_added_services_do_not_change_the_score_against_the_reference():
@ -89,7 +108,8 @@ def test_catalog_is_served_like_the_classifier():
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys() assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(monkeypatch): @pytest.fixture
def lesson(monkeypatch):
sent = [] sent = []
monkeypatch.setattr(hub, "store", MemorySessionStore()) monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event)) monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
@ -98,13 +118,50 @@ def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(monk
level="L1", mode=SessionMode.TRAINING) level="L1", mode=SessionMode.TRAINING)
hub.register(state) hub.register(state)
async def patch(fields): def patch(fields):
async with hub.operation(state.session_id): sent.clear()
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
asyncio.run(patch({"notify_extra": ["Служба, которой нет"]})) async def run():
assert isinstance(sent[-1], ErrorEvent) and "не принято" in sent[-1].message async with hub.operation(state.session_id):
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
asyncio.run(run())
return list(sent)
return state, patch
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
state, patch = lesson
sent = patch({"notify_extra": ["Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
assert state.kio.notify_extra == [] assert state.kio.notify_extra == []
asyncio.run(patch({"notify_extra": ["ФСБ"]})) sent = patch({"notify_extra": ["ФСБ"]})
assert state.kio.notify_extra == ["ФСБ"]
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
"""Пакет отклоняется целиком — фронт должен получить серверные значения
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
state, patch = lesson
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent)
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
assert state.kio.address == "улица Ленина, 14"
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
state, patch = lesson
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
assert state.kio.notify_extra == ["ФСБ"] assert state.kio.notify_extra == ["ФСБ"]

View file

@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
}, source: "operator" }); }, source: "operator" });
socket = reconnect(socket); socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch"); assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
call.send(refused);
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
socket.sent = [];
call.send({ type: "kio.patch", fields: { floor: "2" } });
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 }); const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {}); assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {}); assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});

View file

@ -164,8 +164,17 @@ export const callChannel = (
...options, ...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null, queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => { isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch" if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
|| incoming.source !== "operator") return false; // `auto` по всем полям команды с другим значением — сервер её переписал
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
// могла не дойти, повтор безвреден.
if (incoming.source === "auto") {
const keys = Object.keys(pending.fields);
return keys.every((key) => key in incoming.fields)
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
}
return Object.entries(pending.fields).every(([key, value]) => return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]), sameJsonValue(value, incoming.fields[key]),
); );