From f9d2c2cc776b21375fccf1ab986b704ded41aa9b Mon Sep 17 00:00:00 2001 From: GGlamer <52128225+Gamer201760@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:23:15 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20kio.pat?= =?UTF-8?q?ch=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=81=D0=BB=D1=83=D0=B6?= =?UTF-8?q?=D0=B1=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5,?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=81=20notify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ws/call.py | 27 +++++++--- backend/app/domain/kio.py | 50 +++++++++++------ backend/tests/test_service_catalog.py | 77 +++++++++++++++++++++++---- frontend/scripts/test-ws-outbox.mjs | 12 +++++ frontend/src/shared/api/ws.ts | 13 ++++- 5 files changed, 144 insertions(+), 35 deletions(-) diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 1ce57be..48e350e 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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,25 +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) + old_extra = list(state.kio.notify_extra) try: 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( 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 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)) diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 0c727e3..a8158a8 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -10,7 +10,7 @@ from enum import StrEnum from typing import Any 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 @@ -119,7 +119,9 @@ class KIO(BaseModel): # в боевом АРМ оператор может добавить службу вручную, но не удалить. notify: list[str] = Field(default_factory=list, description="Список оповещения") #: Службы, добавленные оператором вручную из каталога. Автоматический список - #: не заменяют и в оценку по эталону не входят. + #: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе + #: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы + #: загрузку сохранённых снимков занятий и сданных карточек. notify_extra: list[str] = Field( default_factory=list, max_length=20, description="Дополнительно оповестить" ) @@ -135,19 +137,6 @@ class KIO(BaseModel): medical: MedicalDetails | 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( @@ -241,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: @@ -261,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) diff --git a/backend/tests/test_service_catalog.py b/backend/tests/test_service_catalog.py index 5731acd..f6c7a0d 100644 --- a/backend/tests/test_service_catalog.py +++ b/backend/tests/test_service_catalog.py @@ -15,8 +15,8 @@ from pydantic import ValidationError from app.api.ws import call as call_ws from app.domain import ekp -from app.domain.events import ErrorEvent, KioPatchIn, SessionMode -from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, apply_patch +from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode +from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch from app.main import app from app.scenarios.loader import load_file 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 == ["Служба 101"], "автоматический список оператор не правит" - with pytest.raises(ValidationError): + with pytest.raises(PatchRejected): 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(): @@ -89,7 +108,8 @@ def test_catalog_is_served_like_the_classifier(): 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 = [] monkeypatch.setattr(hub, "store", MemorySessionStore()) 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) hub.register(state) - async def patch(fields): - async with hub.operation(state.session_id): - await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields)) + def patch(fields): + sent.clear() - asyncio.run(patch({"notify_extra": ["Служба, которой нет"]})) - assert isinstance(sent[-1], ErrorEvent) and "не принято" in sent[-1].message + async def run(): + 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 == [] - 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 == ["ФСБ"] diff --git a/frontend/scripts/test-ws-outbox.mjs b/frontend/scripts/test-ws-outbox.mjs index 6e11fd9..516396e 100644 --- a/frontend/scripts/test-ws-outbox.mjs +++ b/frontend/scripts/test-ws-outbox.mjs @@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: { }, source: "operator" }); socket = reconnect(socket); 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 }); 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, {}); diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts index f01c9d6..6012c6e 100644 --- a/frontend/src/shared/api/ws.ts +++ b/frontend/src/shared/api/ws.ts @@ -164,8 +164,17 @@ export const callChannel = ( ...options, queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null, isAcknowledged: (incoming, pending) => { - if (pending.type !== "kio.patch" || incoming.type !== "kio.patch" - || incoming.source !== "operator") return false; + if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") 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]) => sameJsonValue(value, incoming.fields[key]), );