fix(svod2): серверный поиск совпадает с экраном — пустое поле contract_detail не затирает contract_summary (R-30, решение пользователя 08.11)

В v3.svod2_lines counterparty/reference/deadline/comment берутся как COALESCE(detail, summary).
Оракулы (Python, JS, векторы) отбрасывают null при слиянии, legacy_full() снова читает
ответ без параметров. Тесты: пустое поле detail, отсутствие строки detail, перекрытие непустым detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 11:45:37 +04:00
parent 7d71001b7c
commit 5db0bd4cee
9 changed files with 104 additions and 54 deletions

View File

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

View File

@ -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
],
[
"Центр",

View File

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

View File

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

View File

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

View File

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

View File

@ -700,9 +700,11 @@ PR создаются по готовности, merge — по команде
Цена ошибки: форма `data` в `view=full` и в прежнем ответе различается только null-полями; фронт PR 2 использует compact. Исправление - `jsonb_strip_nulls` и в full.
46. **2026-11-08 (R-26) — эталон `svod2_legacy_golden.json` перезаписан на ответе test-кода (без null-полей, порядок ключей `success, message, result, count`).** Содержимое сверено с прежним эталоном после удаления null-полей: совпало для всех 10 случаев. Перезапись сделана на БД до миграции 0057, затем проверка пройдена с 0057.
Цена ошибки: эталон перестаёт ловить регрессию, если перезаписан на сломанном коде; снижено сверкой с прежним эталоном.
47. **2026-11-08 (R-27) — `legacy_full` в тестах оракулов строится прямым вызовом `v_svod2_go/rf` (с null-полями), а не GET без параметров.** После AURORA-1848 GET отдаёт `data` без null-полей, и пустая секция `contract_detail` перестаёт затирать поля `contract_summary` значением null, как это делал экран «до» и как реализован серверный поиск.
47. **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).
48. **2026-11-08 (R-28) — `svod2_paging_check.py` перед сверкой склейки `view=full` с прежним ответом убирает null-поля.** Иначе проверка всегда расходилась бы с ответом без null-полей.
Цена ошибки: проверка не видит расхождений только в null-полях.
49. **2026-11-08 (R-29) — на фронте (PR 2) измерение высоты строки `offsetHeight` заменено фиксированной высотой `2.75rem` (AURORA-1836); px для окна страниц считается из `getRootFontSizePx()`, заглушки получают высоту в rem.**
Цена ошибки: если реальная высота строки с данными отличается от 2.75rem (длинный перенос), окно страниц рассчитается неточно; в test строки так же имеют фиксированную высоту.
50. **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 остаётся в силе.

View File

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

View File

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