diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 9e120d4..cad99dd 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -179,6 +179,7 @@ async def get_sheet( sheet=sheet, direction=direction.value if direction else None, sections=sections, + strip_nulls=True, ) db_ms = (time.perf_counter() - t0) * 1000 extra={ diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index e3a2e9f..2a18ecd 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -58,6 +58,7 @@ async def get_summary( sheet=sheet, user=current_user, org_ids=_parse_org_ids(org_ids), + strip_nulls=True, ) db_ms = (time.perf_counter() - t0) * 1000 @@ -105,6 +106,7 @@ async def get_summary2( limit=limit, sort_by=sort_by, sort_direction=sort_direction, + strip_nulls=True, ) db_ms = (time.perf_counter() - t0) * 1000 diff --git a/api/src/repository/row_json.py b/api/src/repository/row_json.py index 27dd415..9a9778b 100644 --- a/api/src/repository/row_json.py +++ b/api/src/repository/row_json.py @@ -5,11 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession ROW_COLUMNS = "row_type, depth, sort_order, data" -def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str: +def row_json_select(row_type: str = "row_type", depth: str = "depth", strip_nulls: bool = False) -> str: """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg.""" + data = "jsonb_strip_nulls(data)" if strip_nulls else "data" return ( f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " - f"'data', data, 'sort_order', sort_order)::text" + f"'data', {data}, 'sort_order', sort_order)::text" ) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 3fc0ed1..22795d6 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -79,9 +79,10 @@ class SheetRepository: sheet: str, direction: str | None, sections: list[str] | None = None, + strip_nulls: bool = False, ) -> list[str]: func_query, params = self._view_source(form_id, sheet, direction, sections) - return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params) + return await fetch_row_texts(self.db, f"{row_json_select(strip_nulls=strip_nulls)} FROM {func_query}", params) async def update_cell( self, diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 8dc0070..444a0bd 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -72,8 +72,9 @@ class SummaryRepository: year: int, sheet: str, org_ids: list[int] | None = None, + strip_nulls: bool = False, ) -> list[str]: - sql = f"{row_json_select()} {self._svod1_source()}" + sql = f"{row_json_select(strip_nulls=strip_nulls)} {self._svod1_source()}" return await fetch_row_texts( self.db, sql, @@ -110,8 +111,9 @@ class SummaryRepository: limit: int | None = None, sort_by: str = "org_name", sort_direction: str = "asc", + strip_nulls: bool = False, ) -> list[str]: - select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3") + select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3", strip_nulls=strip_nulls) return await fetch_row_texts( self.db, f"{select} {self._svod2_source(sheet)}", diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 06a5eb9..f1b24b4 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -41,13 +41,15 @@ class SheetService: direction: str | None, sections: list[str] | None = None, form_id: int | None = None, - form: BudgetForm | None = None + form: BudgetForm | None = None, + strip_nulls: bool = False, ) -> list[str]: return await self.sheet_repo.get_json( form_id=form_id, sheet=sheet, direction=direction, sections=sections, + strip_nulls=strip_nulls, ) async def update_cells( diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 71c9a08..737c351 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -58,6 +58,7 @@ class SummaryService: sheet: str, user: AppUser, org_ids: list[int] | None = None, + strip_nulls: bool = False, ) -> list[str]: org_ids = await self._allowed_org_ids(user, org_ids) if org_ids == []: @@ -66,6 +67,7 @@ class SummaryService: year=year, sheet=sheet, org_ids=org_ids, + strip_nulls=strip_nulls, ) async def get_svod2_json( @@ -78,6 +80,7 @@ class SummaryService: limit: int | None = None, sort_by: str = "org_name", sort_direction: str = "asc", + strip_nulls: bool = False, ) -> list[str]: org_ids = await self._allowed_org_ids(user, org_ids) if org_ids == []: @@ -90,6 +93,7 @@ class SummaryService: limit=limit, sort_by=sort_by, sort_direction=sort_direction, + strip_nulls=strip_nulls, ) async def get_svod2_go_export_rows( diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py index f4a432a..aa63a28 100644 --- a/api/tests/integration/test_heavy_get_json.py +++ b/api/tests/integration/test_heavy_get_json.py @@ -52,9 +52,39 @@ def old_rows(client, coro_factory) -> list[dict]: return client.portal.call(_go) +def _strip_nulls(value): + """Как jsonb_strip_nulls: поля объектов с None удаляются рекурсивно, None в списках и {} остаются.""" + if isinstance(value, dict): + return {k: _strip_nulls(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_strip_nulls(v) for v in value] + return value + + +def test_pg_jsonb_strip_nulls_matches_strip_nulls(client, db_session): + literal = '{"a":{"b":null},"c":[null,{"d":null,"e":1},[{"f":null}]],"g":null,"h":{}}' + + async def _go(): + res = await db_session.execute(text("SELECT jsonb_strip_nulls(CAST(:v AS jsonb))"), {"v": literal}) + return res.scalar_one() + pg = client.portal.call(_go) + if isinstance(pg, str): + pg = json.loads(pg) + assert pg == _strip_nulls(json.loads(literal)) + assert pg == {"a": {}, "c": [None, {"e": 1}, [{}]], "h": {}} + + +def _has_null_field(value) -> bool: + if isinstance(value, dict): + return any(v is None or _has_null_field(v) for v in value.values()) + if isinstance(value, list): + return any(_has_null_field(v) for v in value) + return False + + def expected_sheet(rows: list[dict]) -> list[dict]: - """Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls.""" - return rows + """Ожидаемый ответ листа и сводов по эталону старого пути: data без null-полей (фаза B).""" + return [{**r, "data": _strip_nulls(r["data"])} for r in rows] def get_ok(client, url, headers, params=None) -> list[dict]: @@ -372,3 +402,36 @@ def test_project_report_not_found_unchanged(client, admin_tokens, auth_headers, def test_rf_rollup_foreign_branch_unchanged(client, isp_tokens, auth_headers): r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(isp_tokens)) assert (r.status_code, r.json()) == RF_FOREIGN + + +def test_sheet_and_svod_have_no_null_fields(client, admin_tokens, auth_headers, svod2_plan_line): + h = auth_headers(admin_tokens) + cases = [ + get_ok(client, "/api/v1/form/4/sheet/AHR", h), + get_ok(client, f"/api/v1/svod/1/MAIN/{YEAR}", h), + get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", h), + ] + for result in cases: + assert result + for row in result: + assert not _has_null_field(row["data"]), row + assert row.keys() == {"row_type", "depth", "sort_order", "data"} + + +def test_project_report_keeps_nulls(client, admin_tokens, auth_headers, project_id): + result = get_ok(client, f"/api/v1/projects/{project_id}/report/{YEAR}/LIMIT", auth_headers(admin_tokens)) + assert any(_has_null_field(row["data"]) for row in result) + + +def test_patch_cell_null_is_explicit(client, admin_tokens, auth_headers): + """PATCH не режет null: сначала создаём строку plan значением (upd_form_cell на пустом plan ничего не возвращает), + затем сбрасываем ячейку в null и ждём явный "q1": null в ответе.""" + url = "/api/v1/form/1/sheet/AHR/cell" + params = {"direction": "Support", "sections": "plan,contract_summary"} + h = auth_headers(admin_tokens) + r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": 225}, headers=h) + assert r.status_code == 200, r.text + r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": None}, headers=h) + assert r.status_code == 200, r.text + rows = r.json()["result"] + assert any("q1" in (row["data"].get("plan") or {}) and row["data"]["plan"]["q1"] is None for row in rows), rows diff --git a/api/tests/unit/test_json_list_response.py b/api/tests/unit/test_json_list_response.py index bd2ff43..96d0d9d 100644 --- a/api/tests/unit/test_json_list_response.py +++ b/api/tests/unit/test_json_list_response.py @@ -39,3 +39,11 @@ def test_row_json_select_key_order(): assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith( "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " ) + + +def test_row_json_select_strip_nulls(): + sql = row_json_select(strip_nulls=True) + assert "'data', jsonb_strip_nulls(data), " in sql + assert "'data', data," not in sql + assert row_json_select() == row_json_select(strip_nulls=False) + assert "'data', data, " in row_json_select() diff --git a/openspec/changes/aurora-1848-sheet-json/design.md b/openspec/changes/aurora-1848-sheet-json/design.md index 8f0c63b..fb609b7 100644 --- a/openspec/changes/aurora-1848-sheet-json/design.md +++ b/openspec/changes/aurora-1848-sheet-json/design.md @@ -242,3 +242,6 @@ def list_json_response(rows: list[str], headers: dict[str, str] | None = None) - | 2026-10-28 | R-3 (design #3): для `rf-rollup` вместо сравнения с старым путём — тест фиксации ответа об ошибке; `_add_line` в тестах проектов не реализован | `v3.v_form3_rf_rollup_jsonb` на `test` обращается к несуществующей колонке `r.year` и всегда отдаёт 400 (O-3, вне объёма, миграции запрещены); POST строки проекта даёт 400/409 и рвёт транзакцию теста. Исправляет прежнее утверждение выше о покрытии `rf-rollup` интеграционным тестом эквивалентности: успешный путь `rf-rollup` не покрыт ничем до исправления O-3 | Средняя: регрессия успешного пути `rf-rollup` невидна; follow-up на исправление `r.year` и перевод теста в эквивалентность | | 2026-10-28 | R-4 (design #4): мок в `test_api_split.py` использует `stream=httpx.ByteStream(gz)` вместо `content=gz` | С `content=` httpx не считает `num_bytes_downloaded`, `wire_bytes` был бы 0 | Нет: только тест | | 2026-10-28 | R-5 (design #5): после ревью задачи 7 в `compare_responses.py` `dump` принимает готовый `httpx.Client`, пишет `index.json` в `finally` и записывает сетевые ошибки как `error: `; `compare` сравнивает не-JSON тела побайтно | Ночной `dump` на стенде идёт без присмотра; падение на одном запросе не должно терять остальные | Низкая | +| 2026-10-28 | R-6 (design #6): `test_patch_cell_null_is_explicit` сначала отправляет значение 225, затем `null`, и проверяет явный `"q1": null` в ответе | PATCH `null` по несуществующей строке плана возвращает пустой результат; без предварительного значения проверка не имеет смысла | Нет: только тест | +| 2026-10-28 | R-7 (design #7): `getDataRowId` возвращает `null`, а не `undefined`, когда у строки нет `id`/`line_id` (правка по финальному ревью B, I-3) | После `jsonb_strip_nulls` у групповых строк нет ключа `line_id`; сообщение `row_deleted` уходило без `row_id`, сервер падал на `data["data"]["row_id"]` и рвал WebSocket. Это тот же класс дефекта, что D5.2: ключ в сообщении WebSocket обязан присутствовать | Низкая: функция чистая, покрыта тестом | +| 2026-10-28 | R-8 (design #8): аудит Task 15 дополняется проверкой того, что уходит на сервер (полезная нагрузка WebSocket), а не только отображения | Аудит отображения пропустил I-3 | Нет | diff --git a/perf/perfkit/gen/grow.py b/perf/perfkit/gen/grow.py index 005cd0b..0675a3f 100644 --- a/perf/perfkit/gen/grow.py +++ b/perf/perfkit/gen/grow.py @@ -60,11 +60,27 @@ async def grow_vsp_sheet(api: Api, form_id: int, sheet: str, need: int, org_inde return need +def form4_ids(rows: list[dict]) -> tuple[list[int], list[int]]: + """(vsp_ids, project_ids) из строк листа FORM_4; отсутствующие ключи (data/header/vsp_id/project_id) пропускаются.""" + vsp_ids: set[int] = set() + project_ids: set[int] = set() + for r in rows: + header = (r.get("data") or {}).get("header") or {} + if r.get("row_type") == "INPUT": + vid = header.get("vsp_id") + if vid is not None: + vsp_ids.add(vid) + elif r.get("row_type") == "ITEM": + pid = header.get("project_id") + if pid is not None: + project_ids.add(pid) + return sorted(vsp_ids), sorted(project_ids) + + async def grow_form4_sheet(api: Api, form_id: int, sheet: str, need: int) -> int: path = f"/form/{form_id}/sheet/{sheet}" rows = (await api.get(path))["result"] - vsp_ids = sorted({r["data"]["header"]["vsp_id"] for r in rows if r.get("row_type") == "INPUT" and r["data"]["header"].get("vsp_id")}) - project_ids = sorted({r["data"]["header"]["project_id"] for r in rows if r.get("row_type") == "ITEM"}) + vsp_ids, project_ids = form4_ids(rows) if not project_ids: raise RuntimeError(f"{sheet}: нет проектов") leaves = await leaf_items(api, "FORM_4", sheet) diff --git a/perf/tests/test_gen_grow.py b/perf/tests/test_gen_grow.py index 1b4aa4e..70e7d6e 100644 --- a/perf/tests/test_gen_grow.py +++ b/perf/tests/test_gen_grow.py @@ -1,6 +1,6 @@ import pytest -from perfkit.gen.grow import rows_to_add, TARGETS +from perfkit.gen.grow import form4_ids, rows_to_add, TARGETS @pytest.mark.parametrize("cur,target,exp", [(31, 500, 469), (110, 300, 190), (500, 500, 0), (700, 500, 0), (0, 5, 5)]) @@ -12,3 +12,15 @@ def test_targets_do_not_touch_small_pair_sheets(): # AHR_RENT и FORM_4/AHR остаются малыми (пара для @big) assert ("FORM_2", "AHR_RENT") not in [(f, s) for f, s, _ in TARGETS] assert ("FORM_4", "AHR") not in [(f, s) for f, s, _ in TARGETS] + + +def test_form4_ids_tolerates_absent_keys(): + rows = [ + {"row_type": "INPUT", "data": {"header": {"vsp_id": 3}}}, + {"row_type": "INPUT", "data": {"header": {}}}, + {"row_type": "INPUT", "data": {}}, + {"row_type": "ITEM", "data": {"header": {"project_id": 11}}}, + {"row_type": "ITEM", "data": {"header": {}}}, + {"row_type": "ITEM", "data": {}}, + ] + assert form4_ids(rows) == ([3], [11]) diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index c56e894..cce0cb6 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { toast } from 'react-toastify'; import { useWebSocket } from '../hooks/useWebSocket'; +import { buildCellUpdatedMessage } from '../utils/messageUtils'; import { getRowId } from '../utils/rowUtils'; const RealtimeActionsContext = createContext(null); @@ -310,15 +311,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, const lineId = getRowId(row); const columnKey = getColumnRequestKey(column); - const message = { - event: 'cell_updated', - data: { - line_id: lineId, - line_id_code: row.id, - column: columnKey, - value: value, - }, - }; + const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value }); const sent = sendMessage(message); if (!sent) { diff --git a/web/src/components/RealtimeTable/hooks/useWebSocket.js b/web/src/components/RealtimeTable/hooks/useWebSocket.js index 3e91faf..ee39d77 100644 --- a/web/src/components/RealtimeTable/hooks/useWebSocket.js +++ b/web/src/components/RealtimeTable/hooks/useWebSocket.js @@ -30,7 +30,6 @@ export const useWebSocket = (url, onMessage) => { ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - console.log('WebSocket message received:', data); onMessage(data); } catch (err) { console.error('Failed to parse WebSocket message:', err); diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js index 16f1d35..024993a 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.js @@ -1,4 +1,4 @@ -import { getDataRowId, getParentId } from './rowUtils'; +import { getDataRowId, getParentId } from './rowUtils.js'; export function setNewValueToData(data, colId, rowId, value) { let targetObject = data; @@ -43,60 +43,140 @@ export function setNewValueToData(data, colId, rowId, value) { return true; } -export function updateCells(data, newRows) { - const updatedRows = structuredClone(data); +const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v); - function deepMerge(target, source) { - if (!source || typeof source !== 'object') { - return source; - } - if (!target || typeof target !== 'object') { - target = {}; - } - - Object.keys(source).forEach((key) => { - if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { - target[key] = deepMerge(target[key], source[key]); - } else { - target[key] = source[key]; - } - }); - - return target; +// Неизменяющее слияние. Семантика прежнего deepMerge: объекты сливаются рекурсивно, +// массивы и примитивы заменяются (кроме массива верхнего уровня — он, как и раньше, +// сливается по индексам), null записывается. Если ничего не изменилось — +// возвращается тот же target. `fresh` — узлы, уже созданные в текущем вызове +// updateCells: их можно менять на месте, повторно не копируем. +function mergeImmutable(target, source, fresh) { + if (!source || typeof source !== 'object') { + return source; } - - newRows.forEach((update) => { - const [rowType, depth, sortOrder, newData] = update; - - // Рекурсивная функция для поиска и обновления строки - function findAndUpdate(rows) { - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - - if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { - if (newData && typeof newData === 'object') { - Object.keys(newData).forEach((key) => { - if (row.data && row.data[key] !== undefined) { - row.data[key] = deepMerge(row.data[key], newData[key]); - } else if (row[key]) { - row[key] = newData[key]; - } - }); - } - return true; - } - - if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) { - const found = findAndUpdate(row.subRows); - if (found) return true; - } + let base = target && typeof target === 'object' ? target : null; + let result = base; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const curVal = base ? base[key] : undefined; + const nextVal = isPlainObject(srcVal) ? mergeImmutable(curVal, srcVal, fresh) : srcVal; + if (result && key in result && Object.is(result[key], nextVal)) continue; + if (!result || result === base) { + if (!result) { + result = {}; + fresh.add(result); + } else if (!fresh.has(result)) { + result = Array.isArray(result) ? [...result] : { ...result }; + fresh.add(result); } - return false; } + result[key] = nextVal; + } + return result ?? (base || {}); +} - findAndUpdate(updatedRows); - }); - return updatedRows; +function findRowPath(rows, rowType, depth, sortOrder, path) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + path.push(i); + if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { + return true; + } + if (Array.isArray(row.subRows) && row.subRows.length > 0 && findRowPath(row.subRows, rowType, depth, sortOrder, path)) { + return true; + } + path.pop(); + } + return false; +} + +// Считает, во что превратится строка после слияния. Возвращает null, если менять нечего. +function mergeIntoRow(row, newData, fresh) { + let data = row.data; + let rowPatch = null; + for (const key of Object.keys(newData)) { + const hasData = data && typeof data === 'object'; + if (hasData && data[key] !== undefined) { + const merged = mergeImmutable(data[key], newData[key], fresh); + if (!Object.is(merged, data[key])) { + if (!fresh.has(data)) { + data = { ...data }; + fresh.add(data); + } + data[key] = merged; + } + } else if (row[key]) { + if (!Object.is(row[key], newData[key])) { + rowPatch = rowPatch || {}; + rowPatch[key] = newData[key]; + } + } else if (hasData) { + // Единственное осознанное отличие от прежней реализации: ключ, которого нет + // в row.data, вставляется (раньше пропускался). Нужно для AURORA-1844/1848. + if (!fresh.has(data)) { + data = { ...data }; + fresh.add(data); + } + data[key] = mergeImmutable(undefined, newData[key], fresh); + } + } + if (data === row.data && !rowPatch) return null; + return { data, rowPatch }; +} + +// Не мутирует data. Копируются только массивы и строки на пути к затронутой строке; +// остальные строки и поддеревья сохраняют идентичность. Если ничего не изменилось, +// возвращается тот же data. +export function updateCells(data, newRows) { + let result = data; + const fresh = new WeakSet(); + + for (const update of newRows) { + const [rowType, depth, sortOrder, newData] = update; + if (!newData || typeof newData !== 'object') continue; + + const path = []; + if (!findRowPath(result, rowType, depth, sortOrder, path)) continue; + + // Находим целевую строку по пути (без копирования). + let rows = result; + let row; + for (let level = 0; level < path.length; level++) { + row = rows[path[level]]; + rows = row.subRows; + } + const merged = mergeIntoRow(row, newData, fresh); + if (!merged) continue; + + // Копируем путь сверху вниз, пропуская уже свежие узлы. + if (!fresh.has(result)) { + result = [...result]; + fresh.add(result); + } + let level = result; + for (let d = 0; d < path.length; d++) { + const idx = path[d]; + const isLast = d === path.length - 1; + let node = level[idx]; + if (isLast) { + node = { ...node, ...merged.rowPatch, data: merged.data }; + fresh.add(node); + level[idx] = node; + } else { + if (!fresh.has(node)) { + node = { ...node }; + fresh.add(node); + level[idx] = node; + } + if (!fresh.has(node.subRows)) { + node.subRows = [...node.subRows]; + fresh.add(node.subRows); + } + level = node.subRows; + } + } + } + return result; } export function addRootRow(data, newRowRoot) { diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js new file mode 100644 index 0000000..92bc357 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { updateCells } from './cellUtils.js'; + +// Эталон: прежняя реализация updateCells (до AURORA-1838). +function oldUpdateCells(data, newRows) { + const updatedRows = structuredClone(data); + function deepMerge(target, source) { + if (!source || typeof source !== 'object') return source; + if (!target || typeof target !== 'object') target = {}; + Object.keys(source).forEach((key) => { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + }); + return target; + } + newRows.forEach((update) => { + const [rowType, depth, sortOrder, newData] = update; + function findAndUpdate(rows) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.row_type === rowType && row.depth === depth && row.sort_order === sortOrder) { + if (newData && typeof newData === 'object') { + Object.keys(newData).forEach((key) => { + if (row.data && row.data[key] !== undefined) { + row.data[key] = deepMerge(row.data[key], newData[key]); + } else if (row[key]) { + row[key] = newData[key]; + } + }); + } + return true; + } + if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0 && findAndUpdate(row.subRows)) return true; + } + return false; + } + findAndUpdate(updatedRows); + }); + return updatedRows; +} + +function deepFreeze(o) { + if (o && typeof o === 'object' && !Object.isFrozen(o)) { + Object.freeze(o); + Object.values(o).forEach(deepFreeze); + } + return o; +} + +function makeTree() { + return [ + { + row_type: 'AGG', + depth: 0, + sort_order: 1, + data: { header: { name: 'A' }, sum: { y2025: 10, y2026: 20 }, tags: [1, 2] }, + subRows: [ + { + row_type: 'INPUT', + depth: 1, + sort_order: 2, + data: { header: { name: 'A1' }, sum: { y2025: 4, y2026: 5 }, note: 'x', tags: [] }, + subRows: [], + }, + { + row_type: 'INPUT', + depth: 1, + sort_order: 3, + data: { header: { name: 'A2' }, sum: { y2025: 6, y2026: 15 }, note: null }, + subRows: [], + }, + ], + }, + { + row_type: 'AGG', + depth: 0, + sort_order: 4, + data: { header: { name: 'B' }, sum: { y2025: 1, y2026: 2 } }, + subRows: [{ row_type: 'INPUT', depth: 1, sort_order: 5, data: { sum: { y2025: 1 } }, subRows: [] }], + }, + ]; +} + +test('идентичность: незатронутые строки и поддеревья сохраняются, путь копируется', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 99 } }]]); + assert.notEqual(next, data); + assert.notEqual(next[0], data[0]); + assert.notEqual(next[0].subRows, data[0].subRows); + assert.notEqual(next[0].subRows[0], data[0].subRows[0]); + assert.notEqual(next[0].subRows[0].data, data[0].subRows[0].data); + assert.notEqual(next[0].subRows[0].data.sum, data[0].subRows[0].data.sum); + // не затронуты + assert.equal(next[1], data[1]); + assert.equal(next[0].subRows[1], data[0].subRows[1]); + assert.equal(next[0].data, data[0].data); + assert.equal(next[0].subRows[0].data.header, data[0].subRows[0].data.header); + assert.equal(next[0].subRows[0].data.sum.y2026, 5); + assert.equal(next[0].subRows[0].data.sum.y2025, 99); +}); + +test('вход не мутируется (рекурсивно замороженный)', () => { + const data = deepFreeze(makeTree()); + const snapshot = structuredClone(data); + updateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 1 }, note: null, newKey: { a: 1 } }], + ['AGG', 0, 1, { sum: { y2026: 0 } }], + ]); + assert.deepEqual(data, snapshot); +}); + +test('ничего не изменилось: тот же data', () => { + const data = deepFreeze(makeTree()); + assert.equal(updateCells(data, []), data); + assert.equal(updateCells(data, [['INPUT', 9, 9, { sum: { y2025: 1 } }]]), data); + assert.equal(updateCells(data, [['INPUT', 1, 2, { sum: { y2025: 4 }, note: 'x' }]]), data); + assert.equal(updateCells(data, [['INPUT', 1, 2, null]]), data); +}); + +test('строка не найдена: тот же объект, остальные обновления применяются', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [ + ['INPUT', 7, 7, { sum: { y2025: 1 } }], + ['INPUT', 1, 3, { note: 'z' }], + ]); + assert.equal(next[0].subRows[1].data.note, 'z'); + assert.equal(next[1], data[1]); +}); + +test('пакет: узлы копируются один раз, итог корректен', () => { + const data = deepFreeze(makeTree()); + const next = updateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 11 } }], + ['INPUT', 1, 3, { sum: { y2025: 12 } }], + ['AGG', 0, 1, { sum: { y2025: 23 } }], + ]); + assert.equal(next[0].subRows[0].data.sum.y2025, 11); + assert.equal(next[0].subRows[1].data.sum.y2025, 12); + assert.equal(next[0].data.sum.y2025, 23); + assert.equal(next[0].data.sum.y2026, 20); + assert.equal(next[1], data[1]); + // ссылки на свежие узлы между итерациями не плодятся: родитель тот же в обоих детях + assert.equal(next[0].subRows.length, 2); + assert.deepEqual( + next, + oldUpdateCells(data, [ + ['INPUT', 1, 2, { sum: { y2025: 11 } }], + ['INPUT', 1, 3, { sum: { y2025: 12 } }], + ['AGG', 0, 1, { sum: { y2025: 23 } }], + ]), + ); +}); + +const parityCases = { + 'вложенные объекты': [['INPUT', 1, 2, { sum: { y2025: 7 }, header: { name: 'Z' } }]], + 'массивы заменяются': [ + ['AGG', 0, 1, { tags: [9] }], + ['INPUT', 1, 2, { tags: [3, 4] }], + ], + null: [['INPUT', 1, 2, { note: null, sum: { y2026: null } }]], + 'null вместо объекта и объект вместо null': [ + ['INPUT', 1, 3, { note: { a: 1 } }], + ['INPUT', 1, 2, { header: null }], + ], + 'ключ вне data (поле строки)': [['INPUT', 1, 2, { sort_order: 2, depth: 5 }]], + 'пустой/невалидный update': [ + ['INPUT', 1, 2, {}], + ['INPUT', 1, 2, null], + ['INPUT', 1, 2, undefined], + ], + 'несколько в пакете': [ + ['INPUT', 1, 2, { sum: { y2025: 1 } }], + ['INPUT', 1, 2, { sum: { y2026: 2 } }], + ['AGG', 0, 4, { sum: { y2025: 3 } }], + ], + 'не найдена': [['X', 0, 0, { a: 1 }]], +}; +for (const [name, updates] of Object.entries(parityCases)) { + test(`паритет со старой реализацией: ${name}`, () => { + const data = deepFreeze(makeTree()); + assert.deepEqual(updateCells(data, updates), oldUpdateCells(data, updates)); + }); +} + +test('ожидаемое отличие от старой реализации: отсутствующий в row.data ключ вставляется', () => { + const data = deepFreeze(makeTree()); + const updates = [['INPUT', 1, 2, { fresh: { a: 1, b: null }, extra: 5 }]]; + const old = oldUpdateCells(data, updates); + const next = updateCells(data, updates); + assert.equal('fresh' in old[0].subRows[0].data, false); // раньше пропускался + assert.deepEqual(next[0].subRows[0].data.fresh, { a: 1, b: null }); + assert.equal(next[0].subRows[0].data.extra, 5); + // всё остальное совпадает + const stripped = structuredClone(next); + delete stripped[0].subRows[0].data.fresh; + delete stripped[0].subRows[0].data.extra; + assert.deepEqual(stripped, old); +}); + +const row = (data) => ({ row_type: 'INPUT', depth: 3, sort_order: 1, data }); + +test('AURORA-1848: нет блока q1 — блок с полем вставляется, остальное сохраняется', () => { + const data = deepFreeze([row({ line_id: 7, header: { name: 'X' } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]); + assert.equal(out[0].data.q1.m1, 5); + assert.deepEqual(out[0].data.header, { name: 'X' }); + assert.equal(out[0].data.line_id, 7); +}); + +test('AURORA-1848: блок q1 без поля m1 — поле вставляется, остальные поля q1 сохраняются', () => { + const data = deepFreeze([row({ line_id: 7, q1: { m2: 3 } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]); + assert.deepEqual(out[0].data.q1, { m2: 3, m1: 5 }); +}); + +test('AURORA-1848: очистка — null записывается явно', () => { + const data = deepFreeze([row({ line_id: 7, q1: { m1: 5 } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: null } }]]); + assert.ok('m1' in out[0].data.q1); + assert.equal(out[0].data.q1.m1, null); +}); + +test('AURORA-1848: отсутствующий скалярный ключ верхнего уровня data вставляется', () => { + const data = deepFreeze([row({ header: { name: 'X' } })]); + const out = updateCells(data, [['INPUT', 3, 1, { line_id: 9 }]]); + assert.equal(out[0].data.line_id, 9); +}); + +test('AURORA-1848: блок отсутствует на двух уровнях вглубь — создаётся цепочка, вход не меняется', () => { + const data = deepFreeze([row({ line_id: 7 })]); + const out = updateCells(data, [['INPUT', 3, 1, { plan: { support: { q1: 5 } } }]]); + assert.equal(out[0].data.plan.support.q1, 5); + assert.deepEqual(data[0].data, { line_id: 7 }); +}); + +test('AURORA-1848: два обновления одной строки в одном батче складываются', () => { + const data = deepFreeze([row({ line_id: 7 })]); + const out = updateCells(data, [ + ['INPUT', 3, 1, { q1: { m1: 5 } }], + ['INPUT', 3, 1, { q1: { m2: 3 } }], + ]); + assert.deepEqual(out[0].data.q1, { m1: 5, m2: 3 }); +}); diff --git a/web/src/components/RealtimeTable/utils/messageUtils.js b/web/src/components/RealtimeTable/utils/messageUtils.js new file mode 100644 index 0000000..5e305ed --- /dev/null +++ b/web/src/components/RealtimeTable/utils/messageUtils.js @@ -0,0 +1,10 @@ +// value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2 +export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value }) => ({ + event: 'cell_updated', + data: { + line_id: lineId, + line_id_code: lineIdCode, + column, + value: value === undefined ? null : value, + }, +}); diff --git a/web/src/components/RealtimeTable/utils/messageUtils.test.js b/web/src/components/RealtimeTable/utils/messageUtils.test.js new file mode 100644 index 0000000..2e7bb6c --- /dev/null +++ b/web/src/components/RealtimeTable/utils/messageUtils.test.js @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildCellUpdatedMessage } from './messageUtils.js'; + +const msg = (value) => buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value }); + +test('buildCellUpdatedMessage: undefined → null, ключ value есть после JSON.stringify', () => { + const m = msg(undefined); + assert.deepEqual(m, { event: 'cell_updated', data: { line_id: 1, line_id_code: '0.1', column: 'q1.m1', value: null } }); + assert.match(JSON.stringify(m), /"value":null/); +}); + +test('buildCellUpdatedMessage: прочие значения не меняются', () => { + for (const v of [null, 0, '', '1.5', 42, 'текст']) { + assert.equal(msg(v).data.value, v); + } +}); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index af5efcb..cc3ce5d 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -30,7 +30,7 @@ export function buildRowHierarchy(items, mapItem = (item) => item) { } export function getDataRowId(data) { - return data?.id || data?.line_id; + return data?.id || data?.line_id || null; } export function getRowId(row) { diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 0c40434..7d45956 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy } from './rowUtils.js'; +import { buildRowHierarchy, getDataRowId } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -19,3 +19,10 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']); assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []); }); + +test('AURORA-1848: getDataRowId без id и line_id возвращает null и сериализуется в row_id:null', () => { + assert.equal(getDataRowId({ header: {} }), null); + assert.equal(getDataRowId({ id: 5 }), 5); + assert.equal(getDataRowId({ line_id: 7 }), 7); + assert.ok(JSON.stringify({ row_id: getDataRowId({ header: {} }) }).includes('"row_id":null')); +});