diff --git a/api/alembic/versions/sql/0057_svod2_server_paging.sql b/api/alembic/versions/sql/0057_svod2_server_paging.sql index 36dc5e6..b9dfe6f 100644 --- a/api/alembic/versions/sql/0057_svod2_server_paging.sql +++ b/api/alembic/versions/sql/0057_svod2_server_paging.sql @@ -298,6 +298,8 @@ $function$; -- у contract_summary остаются поля, которых нет у contract_detail, а counterparty, reference, -- deadline и comment берутся только из contract_detail (отсутствие строки contract_detail -- затирает их у contract_summary значением null). Состав колонок сверяет тест. +-- Поправка R-30 (решение пользователя 08.11): экран получает data без null-полей (AURORA-1848), поэтому +-- пустое поле contract_detail НЕ затирает contract_summary: берётся COALESCE(detail, summary). CREATE OR REPLACE FUNCTION v3.svod2_lines( p_sheet varchar, p_year integer, @@ -467,9 +469,13 @@ WHERE q.s IS NULL OR EXISTS ( el.form_id::text, el.org_unit_id::text] END AS ints, CASE WHEN has_contract.v - THEN ARRAY[cs.centralized_flag, cd.counterparty, cd.reference, cd.addenda, cd.subject, - cd.currency, cd.rf_schedule, cd.vat_rate, cd.deadline, cd.payment_scheme, - cd.act, cd.comment, to_jsonb(cd.contract_date) #>> '{}']::text[] + -- counterparty, reference, deadline, comment есть в обеих секциях; экран получает data без + -- null-полей, поэтому пустое поле contract_detail не затирает contract_summary (R-30). + THEN ARRAY[cs.centralized_flag, COALESCE(cd.counterparty, cs.counterparty), + COALESCE(cd.reference, cs.reference), cd.addenda, cd.subject, + cd.currency, cd.rf_schedule, cd.vat_rate, COALESCE(cd.deadline, cs.deadline), + cd.payment_scheme, cd.act, COALESCE(cd.comment, cs.comment), + to_jsonb(cd.contract_date) #>> '{}']::text[] END AS rest_s, CASE WHEN has_contract.v AND q.num_ok THEN ARRAY[cs.total_amount, cs.future_payments_y1, cs.future_payments_y2, cs.other_ssp_amount, diff --git a/api/tests/fixtures/svod2_screen_vectors.json b/api/tests/fixtures/svod2_screen_vectors.json index 5f5987b..311172d 100644 --- a/api/tests/fixtures/svod2_screen_vectors.json +++ b/api/tests/fixtures/svod2_screen_vectors.json @@ -360,32 +360,41 @@ "search": "ромаш", "filters": {}, "line_ids": [ + 1, 2, 9 ], "found": { - "found": 2, + "found": 3, "total": 11 }, "facets": { "total": 11, - "search_total": 2, + "search_total": 3, "smeta_type": [ [ "АХР", 2 + ], + [ + "КВ", + 1 ] ], "smeta_direction": [ [ "Поддержка", 2 + ], + [ + "Развитие", + 1 ] ], "org_name": [ [ "Филиал Ёлки", - 1 + 2 ], [ "Центр", diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 378aa19..b7a30df 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -118,30 +118,18 @@ SVOD2_YEAR = 2031 SVOD2_SEED_PATH = os.path.join(os.path.dirname(__file__), "..", "sqls", "svod2_seed.sql") -def legacy_full(client, sheet: str, headers: dict = None, org_ids=None) -> list[dict]: - """Прежний полный ответ свода 2 без параметров нового режима (оракульная база). +def legacy_full(client, sheet: str, headers: dict, org_ids=None) -> list[dict]: + """Ответ свода 2 без параметров нового режима - то, что получает экран (оракульная база). - Строится прямым вызовом функции представления, а не через GET без параметров: после AURORA-1848 - этот GET отдаёт data без null-полей (jsonb_strip_nulls), а оракул экрана «до» работает с полным - data - в нём пустая секция contract_detail затирает поля contract_summary значением null. - Запрос идёт от имени администратора, headers оставлен для совместимости вызовов. + После AURORA-1848 data в нём без null-полей: оракул экрана «до» работает именно с такими строками + (решение пользователя 08.11, R-30: поиск совпадает с тем, что видно на экране). """ - function_name = {"GO": "v3.v_svod2_go", "RF": "v3.v_svod2_rf"}[sheet] - - async def _fetch(): - result = await client.db_session.execute( - text( - f"SELECT sort_order, data FROM {function_name}(" - "CAST(:year AS INT), CAST(:org_ids AS INT[]), NULL, NULL, 'org_name', 'asc')" - ), - {"year": SVOD2_YEAR, "org_ids": org_ids}, - ) - return [ - {"row_type": "INPUT", "depth": 3, "sort_order": sort_order, "data": data} - for sort_order, data in result.fetchall() - ] - - return client.portal.call(_fetch) + url = f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}" + if org_ids is not None: + url += f"?org_ids={org_ids}" + response = client.get(url, headers=headers) + assert response.status_code == 200, response.text + return response.json()["result"] @pytest.fixture diff --git a/api/tests/integration/test_svod2_paging_api.py b/api/tests/integration/test_svod2_paging_api.py index 8fb4bf3..d6afa9a 100644 --- a/api/tests/integration/test_svod2_paging_api.py +++ b/api/tests/integration/test_svod2_paging_api.py @@ -39,6 +39,14 @@ def _url(sheet: str, suffix: str = "") -> str: return f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}{suffix}" +def _strip_nulls(value): + 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 _line_ids(rows: list[dict]) -> list[int]: return [r["data"]["line_id"] for r in rows] @@ -127,7 +135,8 @@ def test_view_full_rows_equal_legacy_rows(client, svod2_data, headers): assert r.status_code == 200 body = r.json() assert "total" in body - assert body["result"] == full[:50] + # Режим страниц отдаёт data целиком (с null), ответ без параметров - без null-полей (AURORA-1848). + assert _strip_nulls(body["result"]) == full[:50] @pytest.mark.parametrize("sheet", SHEETS) @@ -141,33 +150,53 @@ def test_search_matches_screen_oracle(client, svod2_data, headers, sheet, search assert all(t == len(expected) for t in totals) -# Строка 910004 имеет contract_summary (ООО Лютик, ДС-2, 70), но НЕ имеет contract_detail: -# пустая секция contract_detail перекрывает counterparty/reference из contract_summary -# (семантика экрана), а неперекрытое поле total_amount остаётся доступным для поиска. +# Решение пользователя 08.11 (R-30): серверный поиск совпадает с тем, что видно на экране. Пустое (null) +# поле contract_detail не затирает такое же поле contract_summary: экран получает data без null-полей, +# и в {...header, ...contract_summary, ...contract_detail} побеждает последнее НЕпустое значение. +# Строка 910004 имеет contract_summary (ООО Лютик, ДС-2, 70), но НЕ имеет contract_detail. LINE_NO_DETAIL = 910004 +# Строка 910001: contract_summary (ООО Ромашка, ДС-1), в contract_detail counterparty = NULL, reference = Д-1. +LINE_EMPTY_DETAIL_FIELD = 910001 @pytest.mark.parametrize("sheet", SHEETS) -@pytest.mark.parametrize("search", ["ооо лютик", "дс-2"]) -def test_search_overlapped_contract_summary_field_not_found( +@pytest.mark.parametrize("search", ["ооо лютик", "дс-2", "70"]) +def test_search_summary_value_found_when_detail_row_missing( client, svod2_data, headers, sheet, search ): full = legacy_full(client, sheet, headers) expected = screen_rows(full, search) - assert LINE_NO_DETAIL not in _line_ids(expected) rows, _ = _all_pages(client, sheet, headers, 3, view="compact", search=search) - assert LINE_NO_DETAIL not in _line_ids(rows) + assert _line_ids(rows) == _line_ids(expected) + if sheet == "GO": + assert LINE_NO_DETAIL in _line_ids(rows) -@pytest.mark.parametrize("sheet", ["GO"]) -def test_search_non_overlapped_contract_summary_field_found( - client, svod2_data, headers, sheet +@pytest.mark.parametrize("search,found", [("ооо ромашка", True), ("дс-1", False), ("д-1", True)]) +def test_search_summary_value_found_when_detail_field_is_empty( + client, svod2_data, headers, search, found ): - full = legacy_full(client, sheet, headers) - expected = screen_rows(full, "70") - assert LINE_NO_DETAIL in _line_ids(expected) - rows, _ = _all_pages(client, sheet, headers, 3, view="compact", search="70") - assert LINE_NO_DETAIL in _line_ids(rows) + """detail.counterparty = NULL, summary.counterparty = «ООО Ромашка»: строка находится по значению summary. + + reference у detail непустой (Д-1) и перекрывает summary (ДС-1): по «дс-1» строка не находится. + """ + full = legacy_full(client, "GO", headers) + assert (LINE_EMPTY_DETAIL_FIELD in _line_ids(screen_rows(full, search))) is found + rows, _ = _all_pages(client, "GO", headers, 3, view="compact", search=search) + assert (LINE_EMPTY_DETAIL_FIELD in _line_ids(rows)) is found + + +def test_search_detail_value_overrides_summary_value(client, svod2_data, svod2_sql, headers): + """Непустое поле contract_detail по-прежнему перекрывает contract_summary: старое значение не находится.""" + svod2_sql( + "INSERT INTO v3.contract_summary (line_id, counterparty, reference) " + "VALUES (910002, 'Старый контрагент', 'Старая ссылка')" + ) + for search, found in (("старый контрагент", False), ("ромашка плюс", True), ("старая ссылка", False)): + full = legacy_full(client, "GO", headers) + assert (910002 in _line_ids(screen_rows(full, search))) is found + rows, _ = _all_pages(client, "GO", headers, 3, view="compact", search=search) + assert (910002 in _line_ids(rows)) is found, search @pytest.mark.parametrize("sheet", SHEETS) diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index f8a54dc..5779e21 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -136,7 +136,7 @@ def test_facets_search_subplan_is_not_correlated(svod2_sql): # --- Поиск в svod2_lines (ускорение AURORA-1843): результат равен прежнему поиску по jsonb --- -# Прежний поиск дословно: значения header + contract_summary + contract_detail через jsonb_each, +# Поиск экрана: data без null-полей (AURORA-1848, R-30), значения значения header + contract_summary + contract_detail через jsonb_each, # регистр svod2_casefold по каждому значению, числа через svod2_js_number_text. REFERENCE_SEARCH_SQL = """ SELECT l.line_id @@ -176,7 +176,8 @@ WHERE EXISTS ( ELSE COALESCE(to_jsonb(cd) - 'line_id', e.cd0) END AS cd_j ) s - CROSS JOIN LATERAL jsonb_each(s.header_j || s.cs_j || s.cd_j) kv + CROSS JOIN LATERAL jsonb_each( + jsonb_strip_nulls(s.header_j) || jsonb_strip_nulls(s.cs_j) || jsonb_strip_nulls(s.cd_j)) kv WHERE bl.id = l.line_id AND strpos( v3.svod2_casefold( diff --git a/api/tests/svod2_oracle.py b/api/tests/svod2_oracle.py index 176cfc8..d4caa18 100644 --- a/api/tests/svod2_oracle.py +++ b/api/tests/svod2_oracle.py @@ -70,10 +70,15 @@ def js_string(value: object) -> str: def matches_search(row: dict, query: str) -> bool: data = row.get("data") or {} + # Экран получает data без null-полей (AURORA-1848): пустое поле contract_detail не затирает + # contract_summary (решение пользователя 08.11, R-30). Null отбрасываются, как на сервере. + def present(section): + return {k: v for k, v in (section or {}).items() if v is not None} + merged = { - **(data.get("header") or {}), - **(data.get("contract_summary") or {}), - **(data.get("contract_detail") or {}), + **present(data.get("header")), + **present(data.get("contract_summary")), + **present(data.get("contract_detail")), } return any(query in js_string(v).lower() for v in merged.values()) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index 8f3325c..3d15931 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -705,9 +705,11 @@ PR создаются по готовности, merge — по команде Цена ошибки: форма `data` в `view=full` и в прежнем ответе различается только null-полями; фронт PR 2 использует compact. Исправление - `jsonb_strip_nulls` и в full. 48. **2026-11-08 (R-26) — эталон `svod2_legacy_golden.json` перезаписан на ответе test-кода (без null-полей, порядок ключей `success, message, result, count`).** Содержимое сверено с прежним эталоном после удаления null-полей: совпало для всех 10 случаев. Перезапись сделана на БД до миграции 0057, затем проверка пройдена с 0057. Цена ошибки: эталон перестаёт ловить регрессию, если перезаписан на сломанном коде; снижено сверкой с прежним эталоном. -49. **2026-11-08 (R-27) — `legacy_full` в тестах оракулов строится прямым вызовом `v_svod2_go/rf` (с null-полями), а не GET без параметров.** После AURORA-1848 GET отдаёт `data` без null-полей, и пустая секция `contract_detail` перестаёт затирать поля `contract_summary` значением null, как это делал экран «до» и как реализован серверный поиск. +49. **2026-11-08 (R-27, заменено R-30) — `legacy_full` в тестах оракулов строился прямым вызовом `v_svod2_go/rf` (с null-полями), а не GET без параметров.** После AURORA-1848 GET отдаёт `data` без null-полей, и пустая секция `contract_detail` перестаёт затирать поля `contract_summary` значением null, как это делал экран «до» и как реализован серверный поиск. Цена ошибки: семантика поиска «пустой contract_detail затирает contract_summary» остаётся от экрана «до», живой экран test (на данных без null) её уже не воспроизводит; решение за человеком (deferred). 50. **2026-11-08 (R-28) — `svod2_paging_check.py` перед сверкой склейки `view=full` с прежним ответом убирает null-поля.** Иначе проверка всегда расходилась бы с ответом без null-полей. Цена ошибки: проверка не видит расхождений только в null-полях. 51. **2026-11-08 (R-29) — на фронте (PR 2) измерение высоты строки `offsetHeight` заменено фиксированной высотой `2.75rem` (AURORA-1836); px для окна страниц считается из `getRootFontSizePx()`, заглушки получают высоту в rem.** Цена ошибки: если реальная высота строки с данными отличается от 2.75rem (длинный перенос), окно страниц рассчитается неточно; в test строки так же имеют фиксированную высоту. +52. **2026-11-08 (R-30, решение пользователя 08.11; в поручении названо R-10, номер занят) — серверный поиск совпадает с тем, что показывает экран: пустое (null) поле `contract_detail` НЕ затирает такое же поле `contract_summary`.** Экран получает `data` без null-полей (AURORA-1848), и в `{...header, ...contract_summary, ...contract_detail}` побеждает последнее непустое значение. В `v3.svod2_lines` значения `counterparty`, `reference`, `deadline`, `comment` берутся как `COALESCE(cd.x, cs.x)`; оракулы (`svod2_oracle.py`, `svod2_search_oracle.mjs`, векторы `svod2_screen_vectors.json`) отбрасывают null при слиянии, `legacy_full()` снова читает ответ без параметров. Новые тесты: строка 910001 (`contract_detail.counterparty = NULL`, у `contract_summary` «ООО Ромашка») находится по «ооо ромашка», по «дс-1» не находится (непустой `reference` detail перекрывает); строка без `contract_detail` находится по значениям summary; непустое поле detail перекрывает summary. + Цена ошибки: если пользователь имел в виду иное, поиск расходится с экраном в этих строках; правка - вернуть `cd.x` вместо `COALESCE` в одном месте SQL и убрать отбрасывание null в оракулах. Замечание: R-25 про `view=full` с null остаётся в силе. diff --git a/perf/analysis/svod2_search_oracle.mjs b/perf/analysis/svod2_search_oracle.mjs index 2343a53..b40d9aa 100644 --- a/perf/analysis/svod2_search_oracle.mjs +++ b/perf/analysis/svod2_search_oracle.mjs @@ -4,11 +4,16 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +// Экран получает data без null-полей (jsonb_strip_nulls, AURORA-1848), поэтому пустое поле contract_detail +// не затирает такое же поле contract_summary (решение пользователя 08.11, R-30). Строки оракула могут +// содержать null (полный data режима страниц) - они отбрасываются так же, как это делает сервер. +const withoutNulls = (section) => Object.fromEntries(Object.entries(section ?? {}).filter(([, value]) => value !== null)); + const matchesSearch = (row, query) => { const searchableData = { - ...row.data?.header, - ...row.data?.contract_summary, - ...row.data?.contract_detail, + ...withoutNulls(row.data?.header), + ...withoutNulls(row.data?.contract_summary), + ...withoutNulls(row.data?.contract_detail), }; return Object.values(searchableData).some((value) => String(value ?? '') diff --git a/perf/analysis/svod2_search_oracle.test.mjs b/perf/analysis/svod2_search_oracle.test.mjs index 749a8a5..4d36386 100644 --- a/perf/analysis/svod2_search_oracle.test.mjs +++ b/perf/analysis/svod2_search_oracle.test.mjs @@ -14,9 +14,14 @@ const mk = (id, type, dir, org) => ({ data: { line_id: id, header: { smeta_type: const rows2 = [mk(1, 'КВ', 'Развитие', 'А'), mk(2, 'АХР', 'Поддержка', 'Б')]; const rows3 = [mk(1, 'КВ', 'Р', 'Филиал Ёлки'), mk(2, 'КВ', 'Р', 'Филиал Ёлки'), mk(3, 'КВ', 'Р', null)]; -test('перекрытое поле contract_summary не ищется', () => { +test('пустое поле contract_detail не затирает contract_summary (R-30)', () => { const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } }; + assert.equal(matchesSearch(row, 'ромаш'), true); +}); +test('непустое поле contract_detail перекрывает contract_summary', () => { + const row = { data: { header: {}, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: 'ИП Ёж' } } }; assert.equal(matchesSearch(row, 'ромаш'), false); + assert.equal(matchesSearch(row, 'ёж'), true); }); test('число ищется в записи JS', () => { const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } };