Merge pull request 'AURORA-1848: лист и своды без null-полей + фронт (B)' (#242) from feat/233-aurora-1848-sheet-json-no-pydantic-b into test

This commit is contained in:
aep 2026-11-01 11:16:08 +03:00
commit 8c467804be
18 changed files with 209 additions and 22 deletions

View File

@ -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={

View File

@ -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

View File

@ -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"
)

View File

@ -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,

View File

@ -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)}",

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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()

View File

@ -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: <Exc>`; `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 | Нет |

View File

@ -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)

View File

@ -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])

View File

@ -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) {

View File

@ -200,3 +200,48 @@ test('ожидаемое отличие от старой реализации:
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 });
});

View File

@ -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,
},
});

View File

@ -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);
}
});

View File

@ -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) {

View File

@ -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'));
});