AURORA-1850: правка ячейки пересчитывает только затронутое поддерево #244

Open
aep wants to merge 20 commits from feat/235-aurora-1850-upd-form-cells-subtree into feat/215-aurora-1844-upd-form-cells-full-rows
12 changed files with 7315 additions and 1 deletions

View File

@ -2,7 +2,7 @@
Монорепозиторий: `api/` (FastAPI) отдаёт REST, WebSocket и собранный фронт; `web/` — SPA на React. Основная бизнес-логика — PL/pgSQL-функции схемы `v3` в Postgres. Python-слой проверяет роли, валидирует запрос, вызывает функцию и маппит её ошибки.
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграция 0050).
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём.
## Модули

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0051"
down_revision = "0050"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0051_upd_form_cells_subtree.sql")
def downgrade() -> None:
pass

File diff suppressed because one or more lines are too long

View File

@ -20,3 +20,108 @@ def assert_patch_rows_match_sheet(patch_rows: list[dict], sheet_rows: list[dict]
key = row_key(row)
assert key in sheet_by_key, f"строки {key} нет в GET /sheet"
assert row["data"] == sheet_by_key[key]["data"], f"data строки {key} расходится с GET"
# --- AURORA-1850: эталон (фильтр 0050 поверх полного v_form_view) и хелперы SQL ---
Row4 = tuple[str, int, int, str] # (row_type, depth, sort_order, data::text)
_STRIP_ALL = [
"plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking",
"contract_summary", "allocation", "collegial", "ckk",
"sequestration", "reserve", "contract_detail",
"q1", "q2", "q3", "q4", "totals",
"fact_q1", "fact_q2", "fact_q3", "fact_q4",
]
_SAT_SHEETS = ("AHR_RENT", "AHR_UTILITY", "AHR_SECURITY")
def strip_keys(sections: list[str] | None) -> list[str]:
"""Ключи верхнего уровня исключённых разделов (как v_strip в 0050)."""
if sections is None:
return []
return [k for k in _STRIP_ALL if k not in sections]
def sheet_kind(sheet: str) -> str:
if sheet in _SAT_SHEETS:
return "sat"
if sheet == "AHR_LIMIT":
return "limit"
if sheet == "OTCH9F":
return "otch9f"
return "tree"
_REF_FROM = """
SELECT v.row_type, v.depth, v.sort_order, (v.data - CAST(:strip AS text[]))::text
FROM v3.v_form_view(:f, :s, CAST(:sec AS text[]), CAST(:d AS varchar)) v
WHERE {where}
ORDER BY v.sort_order"""
_REF_WHERE = {
"sat": "v.row_type = 'INPUT' AND (v.data->>'id')::int = ANY(CAST(:ids AS int[]))",
"limit": "(v.data->>'id')::int = ANY(v3._limit_template_path_set(CAST(:ids AS int[])))",
"otch9f": """EXISTS (SELECT 1 FROM v3.fixed_asset_report far
WHERE far.id = ANY(CAST(:ids AS int[]))
AND far.expense_item_id = (v.data->>'expense_item_id')::int
AND far.equipment_name IS NOT DISTINCT FROM (v.data->>'equipment_name'))""",
"tree": """(v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM','SUB_SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::int = ANY(v3._expense_item_path_set(
ARRAY(SELECT DISTINCT bl.expense_item_id FROM v3.budget_line bl
WHERE bl.id = ANY(CAST(:ids AS int[]))))))
OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::int = ANY(CAST(:ids AS int[])))""",
}
def _rows(db_exec, sql: str, **params) -> list:
return [tuple(r) for r in db_exec(sql, **params)]
def reference_affected_rows(db_exec, form_id, sheet, ids, direction=None, sections=None, strip=True):
sql = _REF_FROM.format(where=_REF_WHERE[sheet_kind(sheet)])
return _rows(
db_exec, sql, f=form_id, s=sheet, ids=list(ids), d=direction, sec=sections,
strip=strip_keys(sections) if strip else [],
)
def affected_rows(db_exec, form_id, sheet, ids, direction=None, sections=None):
return _rows(
db_exec,
"""SELECT r.row_type, r.depth, r.sort_order, r.data::text
FROM v3._form_affected_rows(:f, :s, CAST(:ids AS int[]), CAST(:d AS varchar),
CAST(:sec AS text[])) r
ORDER BY r.sort_order""",
f=form_id, s=sheet, ids=list(ids), d=direction, sec=sections,
)
def focus_builder_rows(db_exec, form_type, form_id, sheet, ids, direction=None, sections=None):
if form_type == "FORM_2":
sql = """SELECT r.row_type, r.depth, r.sort_order, r.data::text
FROM v3.v_form2_view(:f, :s, CAST(:sec AS text[]), CAST(:ids AS int[])) r
ORDER BY r.sort_order"""
params = dict(f=form_id, s=sheet, sec=sections, ids=list(ids))
else:
sql = """SELECT r.row_type, r.depth, r.sort_order, r.data::text
FROM v3.v_form1_view(:f, :s, CAST(:sec AS text[]), CAST(:d AS varchar),
CAST(:ids AS int[])) r
ORDER BY r.sort_order"""
params = dict(f=form_id, s=sheet, sec=sections, d=direction, ids=list(ids))
return _rows(db_exec, sql, **params)
def assert_rows_equal(actual: list, expected: list, label: str) -> None:
"""Построчное сравнение; сообщение называет первую отличающуюся строку и длины."""
if actual == expected:
return
n = min(len(actual), len(expected))
first = next((i for i in range(n) if actual[i] != expected[i]), n)
a = actual[first] if first < len(actual) else None
e = expected[first] if first < len(expected) else None
raise AssertionError(
f"{label}: длины actual={len(actual)} expected={len(expected)}; "
f"первое расхождение в позиции {first}:\n actual = {a}\n expected = {e}"
)

View File

@ -0,0 +1,476 @@
"""AURORA-1850: эквивалентность пересчёта поддерева и полной сборки листа.
Эталон — фильтр 0050 поверх полного v3.v_form_view. Новый путь (v3._form_affected_rows и
сборщики v3.v_form2_view / v3.v_form1_view с фокусом по id) обязан давать те же строки.
"""
import uuid
import pytest
from sheet_contract import (
affected_rows,
assert_patch_rows_match_sheet,
assert_rows_equal,
focus_builder_rows,
reference_affected_rows,
row_key,
)
from test_upd_form_cells_full_rows import ( # noqa: F401 (фикстуры и хелперы)
API,
_add_line,
_get_sheet,
_new_form,
_patch_cell,
_qs,
db_exec,
headers,
)
TREE_TYPES = ("ROOT", "GROUP", "ITEM", "SUB_ITEM", "SUB_SUB_ITEM")
SATELLITES = ("AHR_RENT", "AHR_UTILITY", "AHR_SECURITY")
FORM2_SHEETS = ("AHR", "CAP", "OPER", "AHR_RENT", "AHR_UTILITY", "AHR_SECURITY", "AHR_LIMIT", "OTCH9F")
# (form_type, sheet, direction, sections, год новой формы)
CASES = {
"F1-AHR-Support": ("FORM_1", "AHR", "Support", None, 2041),
"F1-CAP-Support": ("FORM_1", "CAP", "Support", None, 2043),
"F1-OPER": ("FORM_1", "OPER", None, None, 2044),
"F2-AHR": ("FORM_2", "AHR", None, None, 2045),
"F2-CAP": ("FORM_2", "CAP", None, None, 2046),
"F2-OPER": ("FORM_2", "OPER", None, None, 2047),
"F2-AHR_RENT": ("FORM_2", "AHR_RENT", None, None, 0),
"F2-AHR_UTILITY": ("FORM_2", "AHR_UTILITY", None, None, 0),
"F2-AHR_SECURITY": ("FORM_2", "AHR_SECURITY", None, None, 0),
"F2-AHR_LIMIT": ("FORM_2", "AHR_LIMIT", None, None, 0),
"F2-OTCH9F": ("FORM_2", "OTCH9F", None, None, 0),
"F2-AHR-plan": ("FORM_2", "AHR", None, ["plan"], 2048),
"F1-AHR-plan": ("FORM_1", "AHR", "Support", ["plan"], 2049),
}
SAT_FORM = 4
def _ordered_leaves(db_exec, form_type, sheet):
"""Листовые статьи, привязанные к типу формы, в порядке mv_expense_item_tree.path."""
return [
(r[0], r[1], list(r[2]))
for r in db_exec(
"""
SELECT ei.id, ei.parent_id, t.path
FROM v3.expense_item ei
JOIN v3.mv_expense_item_tree t ON t.id = ei.id
JOIN v3.expense_item_form_type ft ON ft.expense_item_id = ei.id AND ft.form_type_code = :ft
WHERE ei.sheet = :sheet AND ei.direction = 'Support'
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c WHERE c.parent_id = ei.id)
ORDER BY t.path
""",
ft=form_type, sheet=sheet,
)
]
def _pick_items(db_exec, form_type, sheet):
"""первая, последняя, две сестры, строка из другого ROOT (без повторов)."""
leaves = _ordered_leaves(db_exec, form_type, sheet)
first, last = leaves[0], leaves[-1]
chosen = [first, last]
by_parent = {}
for lf in leaves:
by_parent.setdefault(lf[1], []).append(lf)
sib = next(
(g for g in by_parent.values() if len(g) >= 2 and all(x not in chosen for x in g[:2])), None
)
sibs = sib[:2] if sib else []
assert len(sibs) == 2, "нет пары сестёр, отличных от первой/последней"
n_roots = len({lf[2][0] for lf in leaves})
other = None
if n_roots >= 2:
assert first[2][0] != last[2][0], "первая и последняя статьи в одном ROOT при нескольких ROOT"
# строка из ROOT, отличного от ROOT сестёр
other = next((lf for lf in leaves
if lf[2][0] != sibs[0][2][0] and lf not in chosen + sibs), None)
assert other is not None, "нет строки из другого ROOT"
items = [first[0], last[0]] + [s[0] for s in sibs] + ([other[0]] if other else [])
return items, {"first": first[0], "last": last[0], "sibs": [s[0] for s in sibs],
"other": other[0] if other else None}
def _input_line_ids(rows):
return [r["data"]["line_id"] for r in rows if r["row_type"] == "INPUT"]
def _prepare(client, headers, db_exec, case):
"""Создаёт данные листа. Возвращает (form_id, units, batches)."""
form_type, sheet, direction, sections, year = CASES[case]
kind = "tree" if sheet in ("AHR", "CAP", "OPER") else sheet
if kind == "tree":
form_id = _new_form(client, headers, form_type, year)
items, names = _pick_items(db_exec, form_type, sheet)
lines = []
for n, item in enumerate(items):
lines.append(_add_line(client, headers, form_id, sheet, item, direction))
for n, line in enumerate(lines):
_patch_cell(client, headers, form_id, sheet, line, "plan.q1", 10 + n, direction)
_patch_cell(client, headers, form_id, sheet, lines[0], "header.justification", "обоснование", direction)
batch = list(lines)
if case == "F1-AHR-Support":
dev = _add_line(client, headers, form_id, sheet, items[0], "Development")
_patch_cell(client, headers, form_id, sheet, dev, "plan.q1", 77, "Development")
batch.append(dev)
get_rows = _get_sheet(client, headers, form_id, sheet, direction)
units = _input_line_ids(get_rows)
assert set(lines) <= set(units)
return form_id, units, [batch, batch + [lines[0]]]
if sheet in SATELLITES:
ids = []
for n in range(3):
r = client.post(
f"{API}/{SAT_FORM}/sheet/{sheet}/line",
json={"vsp_id": 1 + n % 3, "contract_number": f"C-{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert r.status_code == 200, r.text
sid = r.json()["result"][0]["data"]["id"]
_patch_cell(client, headers, SAT_FORM, sheet, sid, "plan.q1", 5 + n)
ids.append(sid)
get_rows = _get_sheet(client, headers, SAT_FORM, sheet)
units = [r["data"]["id"] for r in get_rows if r["row_type"] == "INPUT"]
assert set(ids) <= set(units)
return SAT_FORM, units, [ids, ids + [ids[0]]]
if sheet == "AHR_LIMIT":
extra = [r[0] for r in db_exec(
"SELECT id FROM v3.limit_template WHERE row_type = 'LEAF' AND id <> 13 ORDER BY id LIMIT 2")]
ids = [13] + extra
for n, i in enumerate(ids):
_patch_cell(client, headers, SAT_FORM, sheet, i, "qty_q1", 3 + n)
units = [r[0] for r in db_exec("SELECT id FROM v3.limit_template WHERE row_type = 'LEAF' ORDER BY id")]
return SAT_FORM, units, [ids, ids + [ids[0]]]
if sheet == "OTCH9F":
return SAT_FORM, *_prepare_otch9f(client, headers, db_exec)
raise AssertionError(case)
def _prepare_otch9f(client, headers, db_exec):
items = [r[0] for r in db_exec(
"""SELECT ei.id FROM v3.expense_item ei
WHERE ei.sheet = 'AHR'
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c WHERE c.parent_id = ei.id)
AND EXISTS (SELECT 1 FROM v3.expense_item_form_type t
WHERE t.expense_item_id = ei.id AND t.form_type_code = 'FORM_2')
AND NOT EXISTS (SELECT 1 FROM v3.fixed_asset_report far
WHERE far.budget_form_id = :f AND far.expense_item_id = ei.id)
ORDER BY ei.id LIMIT 2""", f=SAT_FORM)]
layout = [(items[0], "E1", 1), (items[0], "E1", 2), (items[0], "E1", 3), (items[0], "E2", 1),
(items[0], None, 1), (items[0], None, 2), (items[1], "E1", 1)]
ids = []
for item, name, month in layout:
ids.append(db_exec(
"""INSERT INTO v3.fixed_asset_report
(budget_form_id, expense_item_id, equipment_name, month,
opening_qty_604, opening_amt_604, acquired_qty_604, acquired_amt_604,
disposed_qty_604, disposed_amt_604,
opening_qty_60415, opening_amt_60415, acquired_qty_60415, acquired_amt_60415)
VALUES (:f, :i, :n, :m, 10, 1000, 2, 200, 1, 100, 5, 500, 1, 50) RETURNING id""",
f=SAT_FORM, i=item, n=name, m=month)[0][0])
return ids, [ids, ids + [ids[0]]]
def _check(db_exec, path, form_type, form_id, sheet, ids, direction, sections, label, must_have_input=False):
if path == "affected":
actual = affected_rows(db_exec, form_id, sheet, ids, direction, sections)
expected = reference_affected_rows(db_exec, form_id, sheet, ids, direction, sections, strip=True)
else:
actual = focus_builder_rows(db_exec, form_type, form_id, sheet, ids, direction, sections)
expected = reference_affected_rows(db_exec, form_id, sheet, ids, direction, sections, strip=False)
assert expected, f"{label}: empty reference"
assert_rows_equal(actual, expected, label)
if must_have_input:
assert any(_has_input(r, ids[0]) for r in actual), f"{label}: нет INPUT id={ids[0]} в результате"
def _has_input(row, i):
import json
if row[0] != "INPUT":
return False
d = json.loads(row[3])
return d.get("line_id") == i or d.get("id") == i
@pytest.mark.parametrize("path", ["affected", "builder"])
@pytest.mark.parametrize("case", [c for c in CASES])
def test_affected_rows_match_reference(client, headers, db_exec, path, case):
form_type, sheet, direction, sections, _ = CASES[case]
form_id, units, batches = _prepare(client, headers, db_exec, case)
assert units, "в листе нет строк"
for i in units:
_check(db_exec, path, form_type, form_id, sheet, [i], direction, sections, f"{case} id={i}",
must_have_input=sheet != "AHR_LIMIT" and sheet != "OTCH9F")
if case == "F1-AHR-Support" and path == "affected":
dev = batches[0][-1]
rows = affected_rows(db_exec, form_id, sheet, [dev], direction, sections)
assert not any(_has_input(r, dev) for r in rows), "Development INPUT попал в результат Support"
for n, batch in enumerate(batches):
_check(db_exec, path, form_type, form_id, sheet, batch, direction, sections, f"{case} batch#{n}")
EDIT_CASES = ["F1-AHR-Support", "F2-AHR", "F2-AHR_RENT", "F2-AHR_LIMIT", "F2-OTCH9F", "delta"]
VF_SIG = "v3.v_form_view(integer,character varying,text[],character varying,integer)"
_CALLS = "pg_stat_get_xact_function_calls(CAST(CAST('" + VF_SIG + "' AS regprocedure) AS oid))"
@pytest.mark.parametrize("case", EDIT_CASES)
def test_edit_path_does_not_call_v_form_view(client, headers, db_exec, case):
# R-2: роль perf — суперпользователь, track_functions доступен; тест не пропускается.
assert db_exec("SELECT rolsuper FROM pg_roles WHERE rolname = current_user")[0][0] is True, (
"для track_functions нужна суперпользовательская роль"
)
if case == "delta":
r = client.post(
f"{API}/{SAT_FORM}/sheet/AHR_RENT/line",
json={"vsp_id": 1, "contract_number": f"C-{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert r.status_code == 200, r.text
rent_id = r.json()["result"][0]["data"]["id"]
_patch_cell(client, headers, SAT_FORM, "AHR_RENT", rent_id, "plan.q1", 100)
call, params = ("SELECT count(*) FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', :i)"), {"i": rent_id}
else:
form_type, sheet, direction, sections, _ = CASES[case]
form_id, units, batches = _prepare(client, headers, db_exec, case)
call = ("SELECT count(*) FROM v3._form_affected_rows(:f, :s, CAST(:ids AS int[]), "
"CAST(:d AS varchar), CAST(:sec AS text[]))")
params = dict(f=form_id, s=sheet, ids=batches[0], d=direction, sec=sections)
db_exec("SET LOCAL track_functions = 'all'")
c0 = db_exec(f"SELECT COALESCE({_CALLS}, 0)")[0][0]
db_exec("SELECT count(*) FROM v3.v_form_view(4, 'AHR_RENT', NULL, NULL)")
c1 = db_exec(f"SELECT COALESCE({_CALLS}, 0)")[0][0]
assert c1 - c0 == 1, "положительный контроль: счётчик не отслеживает v_form_view"
n0 = c1
db_exec(call, **params)
n1 = db_exec(f"SELECT COALESCE({_CALLS}, 0)")[0][0]
assert n1 - n0 == 0, f"v_form_view вызван {n1 - n0} раз на пути правки"
def test_empty_focus_returns_nothing(db_exec):
for s in FORM2_SHEETS:
rows = db_exec(
"SELECT * FROM v3.v_form2_view(4, :s, CAST(NULL AS text[]), CAST('{}' AS int[]))", s=s)
assert rows == [], f"v_form2_view пуст для {s}: {len(rows)}"
rows = db_exec(
"SELECT * FROM v3.v_form1_view(1, 'AHR', CAST(NULL AS text[]), 'Support', CAST('{}' AS int[]))")
assert rows == []
assert db_exec("SELECT * FROM v3._form_affected_rows(4, 'AHR', CAST('{}' AS int[]))") == []
assert db_exec("SELECT * FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', -1)") == []
def _patch_cells(client, headers, form_id, sheet, changes, direction=None):
r = client.patch(
f"{API}/{form_id}/sheet/{sheet}/cells{_qs(direction)}", json={"changes": changes}, headers=headers)
assert r.status_code == 200, r.text
return r.json()["result"]
def test_patch_cells_two_branches_matches_get(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_2", 2054)
items, names = _pick_items(db_exec, "FORM_2", "AHR")
l1 = _add_line(client, headers, form_id, "AHR", names["first"])
l2 = _add_line(client, headers, form_id, "AHR", names["last"])
patch = _patch_cells(client, headers, form_id, "AHR", [
{"line_id": l1, "column": "plan.q1", "value": 11},
{"line_id": l2, "column": "plan.q1", "value": 22},
])
get_rows = _get_sheet(client, headers, form_id, "AHR")
assert_patch_rows_match_sheet(patch, get_rows)
assert {r["data"]["line_id"] for r in patch if r["row_type"] == "INPUT"} == {l1, l2}
keys = [row_key(r) for r in patch]
assert len(keys) == len(set(keys))
roots = {
db_exec("SELECT path[1] FROM v3.mv_expense_item_tree WHERE id = :i", i=i)[0][0]
for i in (names["first"], names["last"])
}
assert len(roots) == 2, "строки l1/l2 в одном ROOT"
root_eids = {r["data"]["header"]["expense_item_id"] for r in patch if r["row_type"] == "ROOT"}
assert len(root_eids) >= 2, root_eids
@pytest.mark.parametrize(
"form_type,direction,year",
[("FORM_2", None, 2055), ("FORM_1", "Support", 2056)],
ids=["FORM_2-AHR", "FORM_1-AHR-Support"],
)
def test_patch_after_add_and_delete_matches_get(client, headers, db_exec, form_type, direction, year):
form_id = _new_form(client, headers, form_type, year)
leaves = _ordered_leaves(db_exec, form_type, "AHR")
step = max(len(leaves) // 5, 1)
items = [leaves[i * step][0] for i in range(5)]
lines = [_add_line(client, headers, form_id, "AHR", it, direction) for it in items]
get0 = _get_sheet(client, headers, form_id, "AHR", direction)
order = [r["data"]["line_id"] for r in get0 if r["row_type"] == "INPUT" and r["data"]["line_id"] in lines]
victim, below = order[2], order[3]
r = client.delete(f"{API}/{form_id}/sheet/AHR/line/{victim}{_qs(direction)}", headers=headers)
assert r.status_code == 200, r.text
patch = _patch_cell(client, headers, form_id, "AHR", below, "plan.q1", 42, direction)
get_rows = _get_sheet(client, headers, form_id, "AHR", direction)
assert_patch_rows_match_sheet(patch, get_rows)
def ident(row):
if row["row_type"] == "INPUT":
return ("L", row["data"]["line_id"])
return ("N", row["data"]["header"]["expense_item_id"], row["row_type"])
by_ident = {ident(r): r["sort_order"] for r in get_rows if r["row_type"] != "META"}
for row in patch:
assert row["sort_order"] == by_ident[ident(row)], ident(row)
def test_patch_form1_development_matches_get(client, headers, db_exec):
form_id = _new_form(client, headers, "FORM_1", 2057)
items, names = _pick_items(db_exec, "FORM_1", "AHR")
sup = _add_line(client, headers, form_id, "AHR", names["first"], "Support")
dev1 = _add_line(client, headers, form_id, "AHR", names["first"], "Development")
dev2 = _add_line(client, headers, form_id, "AHR", names["last"], "Development")
_patch_cell(client, headers, form_id, "AHR", sup, "plan.q1", 5, "Support")
_patch_cell(client, headers, form_id, "AHR", dev2, "plan.q1", 6, "Development")
patch = _patch_cell(client, headers, form_id, "AHR", dev1, "plan.q1", 7, "Development")
assert_patch_rows_match_sheet(patch, _get_sheet(client, headers, form_id, "AHR", "Development"))
assert {r["data"]["line_id"] for r in patch if r["row_type"] == "INPUT"} == {dev1}
def test_patch_otch9f_returns_all_months_of_equipment(client, headers, db_exec):
ids, _ = _prepare_otch9f(client, headers, db_exec)
e1_m1 = ids[0]
item = db_exec("SELECT expense_item_id FROM v3.fixed_asset_report WHERE id = :i", i=e1_m1)[0][0]
before = _get_sheet(client, headers, SAT_FORM, "OTCH9F")
patch = _patch_cell(client, headers, SAT_FORM, "OTCH9F", e1_m1, "b_604.acquired_qty", 9)
get_rows = _get_sheet(client, headers, SAT_FORM, "OTCH9F")
assert_patch_rows_match_sheet(patch, get_rows)
def of_equipment(rows):
return {row_key(r): r for r in rows
if r["row_type"] != "META" and r["data"].get("expense_item_id") == item
and r["data"].get("equipment_name") == "E1"}
expected = of_equipment(get_rows)
assert len(expected) >= 3
assert {row_key(r) for r in patch} == set(expected)
prev = of_equipment(before)
changed = [k for k in expected if expected[k]["data"] != prev[k]["data"]]
assert len(changed) >= 2, "остатки следующих месяцев не пересчитаны"
def _sort_key_candidate(db_exec):
rows = db_exec(
"""
SELECT b.id, b.parent_id
FROM v3.mv_expense_item_tree b
JOIN v3.mv_expense_item_tree a ON a.id = b.parent_id
JOIN v3.expense_item_form_type fb ON fb.expense_item_id = b.id AND fb.form_type_code = 'FORM_2'
JOIN v3.expense_item_form_type fa ON fa.expense_item_id = a.id AND fa.form_type_code = 'FORM_2'
WHERE b.sheet = 'AHR' AND a.depth = 3 AND b.depth = 4
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c WHERE c.parent_id = b.id)
AND NOT EXISTS (SELECT 1 FROM v3.budget_line bl WHERE bl.id = b.id)
ORDER BY b.id LIMIT 1
"""
)
return (rows[0][0], rows[0][1]) if rows else None
def test_sort_key_tie_node_before_line(client, headers, db_exec):
cand = _sort_key_candidate(db_exec)
if cand is None:
pytest.skip("нет статьи AHR глубины 3 с дочерней глубины 4 и свободным id в budget_line")
child, parent = cand
assert child != parent
form_id = _new_form(client, headers, "FORM_2", 2058)
# строка висит на родительской статье, а её id равен id дочерней статьи:
# ключ сортировки INPUT совпадает с ключом узла дочерней статьи (ничья)
db_exec(
"INSERT INTO v3.budget_line (id, budget_form_id, expense_item_id, name) "
"VALUES (:i, :f, :p, 'tie')", i=child, f=form_id, p=parent)
assert db_exec("SELECT expense_item_id FROM v3.budget_line WHERE id = :i", i=child)[0][0] == parent
g1 = _get_sheet(client, headers, form_id, "AHR")
g2 = _get_sheet(client, headers, form_id, "AHR")
assert [row_key(r) for r in g1] == [row_key(r) for r in g2]
idx = next(n for n, r in enumerate(g1) if r["row_type"] == "INPUT" and r["data"]["line_id"] == child)
assert idx > 0
prev = g1[idx - 1]
assert prev["row_type"] in TREE_TYPES and prev["data"]["header"]["expense_item_id"] == child, prev
assert prev["sort_order"] == g1[idx]["sort_order"] - 1
patch = _patch_cell(client, headers, form_id, "AHR", child, "plan.q1", 8)
get_rows = _get_sheet(client, headers, form_id, "AHR")
line_patch = next(r for r in patch if r["row_type"] == "INPUT" and r["data"]["line_id"] == child)
line_get = next(r for r in get_rows if r["row_type"] == "INPUT" and r["data"]["line_id"] == child)
assert line_patch["sort_order"] == line_get["sort_order"]
node_get = next(r for r in get_rows if r["row_type"] in TREE_TYPES
and r["data"]["header"]["expense_item_id"] == child)
assert node_get["sort_order"] == line_get["sort_order"] - 1
assert_patch_rows_match_sheet(patch, get_rows)
def _delta_equals_get(db_exec, sheet, row_id):
delta = [tuple(r) for r in db_exec(
"SELECT row_type, depth, sort_order, data::text "
"FROM v3.get_form2_ahr_satellite_delta(4, :s, :i)", s=sheet, i=row_id)]
full = {r[2]: tuple(r) for r in db_exec(
"SELECT row_type, depth, sort_order, data::text "
"FROM v3.v_form_view(4, 'AHR', NULL, 'Support')")}
assert delta, "дельта пуста"
assert len({r[2] for r in delta}) == len(delta), "sort_order в дельте не уникален"
for row in delta:
assert row[2] in full, f"sort_order {row[2]} нет в полном AHR"
assert_rows_equal([row], [full[row[2]]], f"delta {sheet} sort_order={row[2]}")
return delta
def test_ahr_delta_matches_get_after_rent_edit(client, headers, db_exec):
r = client.post(
f"{API}/{SAT_FORM}/sheet/AHR_RENT/line",
json={"vsp_id": 1, "contract_number": f"C-{uuid.uuid4().hex[:6]}"},
headers=headers,
)
assert r.status_code == 200, r.text
rent_id = r.json()["result"][0]["data"]["id"]
_patch_cell(client, headers, SAT_FORM, "AHR_RENT", rent_id, "plan.q1", 100)
delta = _delta_equals_get(db_exec, "AHR_RENT", rent_id)
import json
assert any(
row[0] == "INPUT" and (json.loads(row[3]).get("plan") or {}).get("q1") == 100 for row in delta
), "в дельте нет INPUT с plan.q1 = 100"
def test_ahr_delta_includes_sub_sub_item_for_depth4_limit(client, headers, db_exec):
rows = db_exec(
"""
SELECT lt.id, t.id
FROM v3.limit_template lt
JOIN v3.mv_expense_item_tree t ON t.num_group_id = lt.expense_item_code
JOIN v3.expense_item_form_type ft ON ft.expense_item_id = t.id AND ft.form_type_code = 'FORM_2'
WHERE lt.row_type = 'LEAF' AND t.sheet = 'AHR' AND t.depth = 4
ORDER BY lt.id LIMIT 1
"""
)
if not rows:
pytest.skip("нет LEAF шаблона лимитов с кодом статьи AHR глубины 4")
lt_id, eid = rows[0]
assert lt_id == 13, f"ожидался LEAF 13, найден {lt_id}"
_patch_cell(client, headers, SAT_FORM, "AHR_LIMIT", lt_id, "qty_q1", 4)
delta = _delta_equals_get(db_exec, "AHR_LIMIT", lt_id)
import json
assert any(
row[0] == "SUB_SUB_ITEM" and json.loads(row[3])["header"]["expense_item_id"] == eid for row in delta
), "в дельте нет SUB_SUB_ITEM статьи глубины 4"
JIT_OFF_FUNCS = ("v_form2_sheet_sections", "v_form1_sheet_sections",
"_form_affected_rows", "get_form2_ahr_satellite_delta")
def test_jit_off_on_builders_and_edit_path(db_exec):
rows = db_exec(
"""SELECT p.proname, COALESCE(p.proconfig, '{}') FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'v3' AND p.proname = ANY(CAST(:names AS text[]))""",
names=list(JIT_OFF_FUNCS))
assert {r[0] for r in rows} == set(JIT_OFF_FUNCS)
missing = [r[0] for r in rows if "jit=off" not in r[1]]
assert not missing, f"нет SET jit = off: {missing}"

View File

@ -0,0 +1,47 @@
"""AURORA-1850: защита правки 0037 (LEAF-лимиты АХР и сопоставление num_group_id) после 0051.
Откат 0037 — нарушение корректности: тесты должны падать, если тело функций вернулось к виду до 0037.
"""
import re
from decimal import Decimal
import pytest
from test_upd_form_cells_full_rows import ( # noqa: F401 (фикстуры и хелперы)
_get_sheet,
_patch_cell,
db_exec,
headers,
)
def test_v_form2_view_keeps_0037_limit_formula(client, headers, db_exec):
# LEAF «- принтеры», limit_template.id = 13, код 204620030 (0013_form2_limit.sql:1707)
_patch_cell(client, headers, 4, "AHR_LIMIT", 13, "qty_q1", 4)
row = next(r for r in _get_sheet(client, headers, 4, "AHR_LIMIT") if r["data"].get("id") == 13)
assert Decimal(str(row["data"]["amount_q1"])) == Decimal(4) * Decimal("0.57") * 10900 / 4
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
s="v3.v_form2_view(integer,character varying,text[],integer[])")[0][0]
assert "_form2_ahr_limit_amount" in src
OLD_DEPTH3_MATCHER = re.compile(r"ei\.depth\s*=\s*3\s+AND\s*\(") # вид до 0037
def test_ahr_delta_keeps_0037_num_group_matching(client, headers, db_exec):
found = db_exec(
"""SELECT lt.id FROM v3.limit_template lt
JOIN v3.expense_item ei ON ei.num_group_id = lt.expense_item_code
AND ei.sheet = 'AHR' AND ei.direction = 'Support' AND ei.depth = 4
WHERE lt.row_type = 'LEAF' ORDER BY lt.id LIMIT 1""")
if not found:
pytest.skip("нет LEAF шаблона с кодом = num_group_id статьи AHR глубины 4")
lt_id = found[0][0]
_patch_cell(client, headers, 4, "AHR_LIMIT", lt_id, "qty_q1", 4)
delta = db_exec("SELECT row_type FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)", i=lt_id)
assert any(r[0] == "INPUT" for r in delta), "дельта АХР пуста — откат сопоставления 0037"
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
s="v3.get_form2_ahr_satellite_delta(integer,character varying,integer)")[0][0]
assert "ei.num_group_id = lt.expense_item_code" in src
assert not OLD_DEPTH3_MATCHER.search(src)

View File

@ -0,0 +1,799 @@
# AURORA-1850: правка ячейки пересчитывает только затронутое поддерево. План реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Ветка:** `feat/235-aurora-1850-upd-form-cells-subtree` (Gitea #235, Jira AURORA-1850, эпик AURORA-1834).
**База стека:** `feat/215-aurora-1844-upd-form-cells-full-rows` (PR #231, миграция 0050). PR этой ветки идёт в базу стека, не в `test`.
**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1850`
**Goal:** `v3._form_affected_rows` для FORM_1 и FORM_2 перестаёт собирать лист целиком. Ответ на правку строят те же построители, что и GET, но с фокусом на изменённые строки и их предков. `sort_order` при этом остаётся глобальным.
**Architecture:** Построители `v_form{1,2}_sheet_sections` → `v_form{1,2}_sheet_jsonb` → `v_form2_view` / новая `v_form1_view` получают последним параметром `p_focus_lines integer[] DEFAULT NULL`. NULL — полный лист, `'{}'` — пустой результат. `sort_order` считается в построителе по «скелету» листа (`_sort_rank`) одинаково в обоих режимах, с tie-break D6. `_form_affected_rows` и дельта АХР вызывают построители с фокусом. Всё делается одной миграцией `0051_upd_form_cells_subtree` (DROP старых сигнатур без CASCADE, затем CREATE).
**Tech Stack:** PostgreSQL (PL/pgSQL, LANGUAGE sql), совместимость с 15.12; Alembic (`run_sql_file_migration`); pytest + FastAPI `TestClient` (интеграционные тесты на PG16, контейнер `perf-pg`).
**Spec:** `openspec/changes/aurora-1850-upd-form-cells-subtree/` (`proposal.md`, `design.md`, `specs/sheet-cell-edit/spec.md`). Решения D1–D9, Risks, «Тестирование» и «Замер до/после» в `design.md` обязательны. Ссылки вида «D4», «п. 7» ниже ведут туда.
## Global Constraints
**Окружение (правила волны 2, обязательны для всех исполнителей и ревьюеров):**
- Переменные, которыми пользуется план:
- `W=/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1850`
- `A=$W/.superpowers/aurora-1850` — локальные артефакты, каталог в `.gitignore`;
- `PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin` — свой venv не создавать;
- `DBURL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1850`.
- **Замок ночного прогона.** Перед КАЖДЫМ тяжёлым шагом (pytest, alembic, docker, psql в контейнере) выполнить `$A/wait_lock.sh`: он ждёт, пока существует `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK`. Ожидание запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго — повторять проверку. Чтение кода, правки файлов и git-коммиты при замке разрешены. Контроллер передаёт это правило в бриф каждого сабагента.
- БД: только `dfip_1850`, `dfip_1850_base` и `dfip_1850_snap` в контейнере `perf-pg` (порт 55432). **БД `perf` не трогать никогда.** Стенд `perf/` и порты 8000/8080/8081/8082 не запускать. Замеры делает главный контроллер.
- Полный pytest: `cd $W/api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore`. Исходно падают ровно 3 чужих теста: `test_backend_calls_form3_add_project_and_add_line_functions` и два в `test_export_service_query_params.py`. Новых падений быть не должно.
- PG 15.12 проверяет главный контроллер (контейнер `dfip-pg15`, порт 55434), исполнитель туда не ходит. SQL пишется без возможностей PG16: без `any_value`, у каждого подзапроса в FROM есть алиас.
- Карта кода — `mcp__codegraph__codegraph_explore` с `projectPath=$W`. Если ответ пуст, один раз отметить это в ledger и дальше пользоваться Read/Grep.
- `.bounded` не создавать: правки в `api/alembic` разрешает этот план.
**Код (из design.md):**
- Миграция: `api/alembic/versions/0051_upd_form_cells_subtree.py` (`revision = "0051"`, `down_revision = "0050"`, `downgrade()` пустой) и `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql`.
- `api/src`, `web/`, `perf/` не меняются. Сигнатуры `v_form_view`, `_form_affected_rows`, `upd_form_cell(s)` и `get_form2_ahr_satellite_delta` прежние.
- `p_focus_lines integer[] DEFAULT NULL` — последний параметр у `v_form2_sheet_sections`, `v_form2_sheet_jsonb`, `v_form2_view`, `v_form1_sheet_sections`, `v_form1_sheet_jsonb` и `v_form1_view`. `NULL` — полный лист, `'{}'` — пустой результат. Вызывающие передают `COALESCE(..., '{}')`.
- Узлы фокуса: `_expense_item_path_set(eid строк фокуса)` ∩ `tw`. eid берутся из `budget_line` без фильтра `direction`. INPUT фокуса — строки фокуса, прошедшие обычный фильтр `page`.
- `_sort_rank bigint` — новая последняя выходная колонка `*_sheet_sections` и `*_sheet_jsonb`. Это `ROW_NUMBER() OVER (ORDER BY _sort, line_id NULLS FIRST)` по скелету: все узлы `tw` плюс все строки `page ⋈ tw`, без jsonb и сумм. Скелет одинаков в обоих режимах. View берут `sort_order` из `_sort_rank`. Скелет (D2) без контроллера не откатывать.
- OTCH9F: ранг `ROW_NUMBER() OVER (ORDER BY expense_item_id, month, id)`.
- Порядок в 0051 (D9): сначала все `DROP FUNCTION` без CASCADE и без `IF EXISTS` — `v_form2_view`, `v_form2_sheet_jsonb`, `v_form2_sheet_sections`, `v_form1_sheet_jsonb`, `v_form1_sheet_sections`. Затем `CREATE` по порядку: `*_sheet_sections`, `*_sheet_jsonb`, `v_form2_view`, `v_form1_view`, `v_form_view` (`CREATE OR REPLACE`), `_form_affected_rows` (`CREATE OR REPLACE`), `get_form2_ahr_satellite_delta` (`CREATE OR REPLACE`).
- Формат SQL-файла (`api/alembic/sql_migration.py`): строки, начинающиеся с `--`, выбрасываются. `AS $function$` и закрывающий `$function$` стоят на разных строках, закрывающий — в начале строки. После каждого оператора идёт `;`.
- Тела функций — буквальные, взятые из `pg_get_functiondef` с `dfip_1850` на ревизии 0050 (файл `before-0051.sql`, Task 1) и правленные вручную. `regexp_replace` поверх `pg_get_functiondef` не используется (D8).
- `SET jit = off` ставится только на `v_form2_sheet_sections`, `v_form1_sheet_sections`, `_form_affected_rows` и `get_form2_ahr_satellite_delta`. Конфигурацию сервера и `work_mem` не трогать.
- `_form_affected_rows` объявляется `STABLE`. Ветка FORM_4 и листы, которые не редактируются (SMETA), остаются на полной сборке.
**Стоп и итерации (design.md, Risks; решение 9):**
- **Нарушение корректности — стоп и эскалация контроллеру, без обходов.** Сюда относятся: расхождение эквивалентности (ответ на правку ≠ полный GET), расхождение снимка GET до и после миграции (кроме строк с совпадающим ключом, D6), падение защиты 0037, падение миграции на PG15.
- **Регресс производительности** (GET больше +10 %, правка больше 50 мс, очередь на rate 5) мерж не блокирует. Итерировать можно **только на пути правки** и строго по шагам:
1. `SET plan_cache_mode = force_custom_plan` на `_form_affected_rows` и дельте;
2. снять `SET jit = off` с построителей, оставив его на `_form_affected_rows` и дельте;
3. если не помогло — стоп и вопрос контроллеру с `EXPLAIN (ANALYZE, BUFFERS)` запроса правки и GET.
Недобор записывается в PR и уходит в follow-up.
**Коммиты, PR, маркировка:**
- Стиль `perf(sql): …`, `test(sheet): …`, `docs(...): …`, по-русски, `git add` только конкретных файлов. Последняя строка — `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Не мержить, не пушить в `test`/`main`, `git stash` без тега не использовать.
- Решение, принятое без обсуждения, дописывается строкой в `openspec/changes/aurora-1850-upd-form-cells-subtree/design.md` → `## Decisions made without discussion`.
- Маркеры: `T<n>.<k>` — minor-находки ревью задачи n; `C-n`/`I-n`/`M-n` — финальное ревью; `R-n` — рулинги контроллера (рядом номер строки в design.md); `O-n` — наблюдения; `H-n` — замечания человека в PR. Статус каждой находки — `fixed <commit>`, `deferred` или `rejected <причина>`. Таблица находок ведётся в ledger, раздел `## Findings`.
## Review Focus
1. **Пустой фокус превращается в полный лист.** `array_agg` по пустому набору даёт NULL, и вызов с `p_focus_lines = NULL` вернёт весь лист. Ожидание: `'{}'` даёт 0 строк у каждого построителя, у `_form_affected_rows` и у дельты. Тест `test_empty_focus_returns_nothing` (Task 2).
2. **Строка FORM_1 чужого направления в пакете.** Правка строки Development при `direction=Support` возвращает предков (eid без фильтра направления), но не саму строку. Тест: случай FORM_1/AHR/Support в `test_affected_rows_match_reference` включает в пакет строку Development (Task 2).
3. **OTCH9F с `equipment_name IS NULL`.** Разделы окна по NULL-оборудованию должны совпасть с полным листом: в фильтре `base` нужен `IS NOT DISTINCT FROM`, а не `=`. Тестовые данные OTCH9F содержат оборудование NULL (Task 2), реализация в Task 4.
4. **Скелет сателлитов теряет или добавляет строки относительно GET.** В GET есть `JOIN v3.budget_line` и `JOIN v3.vsp`. Если скелет нумерует по `rent_detail` без того же соединения, `sort_order` сдвинется. Ожидание: в скелете тот же набор строк, что в GET. Тест: эквивалентность сателлитов по каждой строке (Task 2), реализация в Task 4.
5. **Фокус при `sections`.** С `p_sections` построитель в режиме фокуса должен давать те же байты, что полный лист с тем же `sections`, а `_form_affected_rows` должен вырезать ключи исключённых разделов. Тест: случай `sections=['plan']` в `test_affected_rows_match_reference` (Task 2) и существующий `test_sections_filter_drops_other_sections_keys`.
---
## Карта файлов
| Файл | Что | Задачи |
|---|---|---|
| `$A/wait_lock.sh`, `$A/reset_db.sh`, `$A/snap.sh`, `$A/snap_seed.sql`, `$A/snap_query.sql` | локальные скрипты: замок, сброс тестовой БД, снимок GET; не коммитятся | 1 |
| `openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql` | тела 8 функций с ревизии 0050 (`pg_get_functiondef`): исходник для D8, база для ревью и отката | 1 |
| `api/tests/integration/sheet_contract.py` | SQL-хелперы эквивалентности: эталон 0050 поверх `v_form_view`, фактический ответ, сравнение | 2 |
| `api/tests/integration/test_upd_form_cells_subtree.py` | тесты эквивалентности, отсутствия полной сборки, пустого фокуса, `sort_order`, дельты АХР, JIT | 2, 8 |
| `api/tests/integration/test_upd_form_cells_subtree_0037_guard.py` | защита правки 0037 | 9 |
| `api/alembic/versions/0051_upd_form_cells_subtree.py` | обёртка миграции | 3 |
| `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql` | все функции изменения | 3–8 |
| `ARCHITECTURE.md` | абзац о `_form_affected_rows` | 10 |
| `openspec/changes/aurora-1850-upd-form-cells-subtree/design.md` | решения без обсуждения (дописываются по ходу) | все |
## Модели исполнителей
| Задача | Модель | Почему |
|---|---|---|
| 1. Подготовка БД, тела функций, снимок GET | sonnet | окружение и SQL-посев по описанию |
| 2. Красные тесты эквивалентности | sonnet | подбор данных фикстуры, интеграция с API |
| 3. FORM_2 AHR/CAP/OPER: фокус и `_sort_rank` | sonnet | SQL-интеграция, 700-строчный построитель |
| 4. FORM_2: сателлиты, AHR_LIMIT, OTCH9F | sonnet | SQL-интеграция |
| 5. FORM_1: фокус, `_sort_rank`, `v_form1_view` | sonnet | SQL-интеграция |
| 6. `_form_affected_rows` | sonnet | SQL-интеграция |
| 7. Дельта АХР | haiku | тело задано планом и дампом |
| 8. JIT на функциях | haiku | готовый тест и атрибуты |
| 9. Защита 0037 | haiku | готовые тесты |
| 10. ARCHITECTURE, прогон с нуля, снимок GET | haiku | команды и текст заданы |
| Ревью задач | sonnet | — |
| Финальное ревью всей ветки | **opus** | — |
---
### Task 1: Тестовая БД на ревизии 0050, тела функций, снимок GET «до»
Шаг идёт **до** создания 0051 (D8, «Тестирование»).
**Files:**
- Create (не коммитятся): `$A/wait_lock.sh`, `$A/reset_db.sh`, `$A/snap.sh`, `$A/snap_seed.sql`, `$A/snap_query.sql`, `$A/get-before.tsv`
- Create: `openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql`
**Interfaces:**
- Produces:
- БД `dfip_1850_base` (ревизия 0050 + `tests/sqls/fixture.sql`) — шаблон для сброса;
- `$A/reset_db.sh` — пересоздаёт `dfip_1850` из шаблона и выполняет `alembic upgrade head`;
- `$A/snap.sh before|after` — снимок GET на засеянной `dfip_1850_snap`;
- `before-0051.sql` — исходные тела функций.
- [ ] **Step 1: Скрипты замка и сброса**
`$A/wait_lock.sh`:
```bash
#!/usr/bin/env bash
L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK
while [ -e "$L" ]; do sleep 60; done
```
`$A/reset_db.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
A="$(cd "$(dirname "$0")" && pwd)"; W="$(cd "$A/../.." && pwd)"
PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin
"$A/wait_lock.sh"
docker exec perf-pg dropdb -U perf --if-exists dfip_1850
docker exec perf-pg createdb -U perf -T dfip_1850_base dfip_1850
cd "$W/api" && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1850 "$PY/alembic" upgrade head
```
`chmod +x` на оба.
- [ ] **Step 2: Базовый прогон на 0050 и шаблон**
Run:
```bash
$A/wait_lock.sh && docker exec perf-pg dropdb -U perf --if-exists dfip_1850 && docker exec perf-pg createdb -U perf dfip_1850 \
&& cd $W/api && DATABASE_URL=$DBURL $PY/pytest --init_db -q -p no:cacheprovider -W ignore
```
Expected: падают ровно 3 исходных теста, тесты `test_upd_form_cells_full_rows.py` проходят. Итоговую строку записать в ledger.
Затем создать шаблон:
```bash
docker exec perf-pg dropdb -U perf --if-exists dfip_1850_base && docker exec perf-pg createdb -U perf -T dfip_1850 dfip_1850_base
```
- [ ] **Step 3: Снять тела функций**
```bash
$A/wait_lock.sh && docker exec perf-pg psql -U perf -d dfip_1850 -At -c "
SELECT string_agg(pg_get_functiondef(CAST(s AS regprocedure)) || ';', E'\n\n' ORDER BY o)
FROM unnest(ARRAY[
'v3.v_form2_sheet_sections(integer,character varying,text[])',
'v3.v_form2_sheet_jsonb(integer,character varying,text[])',
'v3.v_form2_view(integer,character varying,text[])',
'v3.v_form1_sheet_sections(integer,character varying,character varying,text[])',
'v3.v_form1_sheet_jsonb(integer,character varying,character varying,text[])',
'v3.v_form_view(integer,character varying,text[],character varying,integer)',
'v3._form_affected_rows(integer,character varying,integer[],character varying,text[])',
'v3.get_form2_ahr_satellite_delta(integer,character varying,integer)'
]) WITH ORDINALITY AS t(s, o)" > $W/openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql
```
Первой строкой файла дописать комментарий: `-- pg_get_functiondef с dfip_1850, ревизия 0050 (AURORA-1850, D8). Не миграция: исходник для 0051, база ревью и отката.`
Проверить, что дамп снят после 0037:
```bash
grep -c "_form2_ahr_limit_amount" before-0051.sql # ≥ 4 (v_form2_view, AHR_LIMIT)
grep -n "ei.num_group_id = lt.expense_item_code" before-0051.sql # есть в get_form2_ahr_satellite_delta
```
Если хотя бы одна проверка не проходит — стоп и эскалация: значит, БД не на 0050.
- [ ] **Step 4: Найти вызывающих построители**
```bash
docker exec perf-pg psql -U perf -d dfip_1850 -At -c "
SELECT p.oid::regprocedure FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'v3' AND p.prosrc ~ '(v_form[12]_sheet_(sections|jsonb)|v_form2_view)\s*\('"
```
Записать список в ledger. Для каждой функции вне списка из 8 проверить по `prosrc`, что вызов идёт по позиции не больше чем с прежним числом аргументов и не через `SELECT *` в запись фиксированной формы. Иначе новая колонка `_sort_rank` её сломает. Ожидается, например, `v3.v_form1_smeta`: она выбирает колонки по имени, это безопасно. Если найдена опасная — стоп и вопрос контроллеру.
- [ ] **Step 5: Снимок GET «до» на засеянных данных**
`$A/snap_seed.sql` — один `DO`-блок, детерминированный (строки вставляются `ORDER BY`):
- создать формы `v3.add_budget_form('FORM_1', 2050, 1)` и `v3.add_budget_form('FORM_2', 2050, 1)`;
- на листах AHR/CAP/OPER каждой формы вставить `budget_line` на каждую третью листовую статью (`row_number() OVER (PARTITION BY form, sheet ORDER BY ei.id) % 3 = 1`), привязанную к типу формы через `expense_item_form_type`. `direction` = `ei.direction` для FORM_1 и NULL для FORM_2. Обязательные колонки `budget_line` добавить по схеме таблицы;
- по каждой паре (форма, лист, направление) выполнить `PERFORM * FROM v3.upd_form_cells(form, sheet, <jsonb-массив {line_id, column:'plan.q1', value: id % 97 + 1}>, direction)`, а для половины строк ещё и `plan.q3`;
- вставить в `v3.fixed_asset_report` формы FORM_2: две статьи, которые попадают в ветку OTCH9F `v_form2_view` (`JOIN v3.expense_item`); у первой — оборудование `'E1'` за месяцы 1–3, `'E2'` за месяц 1 и `NULL` за месяцы 1–2; у второй — `'E1'` за месяц 1. Заполнить `opening_*`, `acquired_*` и `disposed_*`;
- `INSERT INTO v3.form_limit (budget_form_id, template_id, qty_q1)` — для трёх LEAF шаблона, включая `id = 13`.
`$A/snap_query.sql`:
```sql
WITH s(ft, sheet, dir) AS (VALUES
('FORM_1','AHR','Support'),('FORM_1','AHR','Development'),('FORM_1','CAP','Support'),
('FORM_1','CAP','Development'),('FORM_1','OPER',NULL),('FORM_1','SMETA',NULL),
('FORM_2','AHR',NULL),('FORM_2','CAP',NULL),('FORM_2','OPER',NULL),('FORM_2','AHR_LIMIT',NULL),
('FORM_2','AHR_RENT',NULL),('FORM_2','AHR_UTILITY',NULL),('FORM_2','AHR_SECURITY',NULL),
('FORM_2','OTCH9F',NULL),('FORM_2','SMETA',NULL))
SELECT bf.id, s.sheet, COALESCE(s.dir, '-'), v.sort_order, v.row_type, v.depth, v.data::text
FROM v3.budget_form bf
JOIN s ON s.ft = bf.form_type_code
CROSS JOIN LATERAL v3.v_form_view(bf.id, s.sheet, NULL, s.dir) v
ORDER BY 1, 2, 3, 4;
```
`$A/snap.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
A="$(cd "$(dirname "$0")" && pwd)"; W="$(cd "$A/../.." && pwd)"
PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin
PSQL="docker exec -i perf-pg psql -U perf -v ON_ERROR_STOP=1 -q"
"$A/wait_lock.sh"
docker exec perf-pg dropdb -U perf --if-exists dfip_1850_snap
docker exec perf-pg createdb -U perf -T dfip_1850_base dfip_1850_snap
$PSQL -d dfip_1850_snap < "$A/snap_seed.sql"
if [ "${1:?before|after}" = after ]; then
(cd "$W/api" && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1850_snap "$PY/alembic" upgrade head)
fi
$PSQL -d dfip_1850_snap -At -F $'\t' < "$A/snap_query.sql" > "$A/get-$1.tsv"
wc -l "$A/get-$1.tsv"
```
Run: `$A/snap.sh before`, затем `$A/snap.sh before` ещё раз, сохранив первый результат под другим именем, и `diff` двух файлов.
Expected: `get-before.tsv` содержит не меньше 500 строк, повторный прогон даёт идентичный файл (посев детерминирован).
- [ ] **Step 6: Коммит**
```bash
git add openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql
git commit -m "docs(openspec): aurora-1850 — тела функций до 0051 (pg_get_functiondef, ревизия 0050)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Красные тесты эквивалентности
**Files:**
- Modify: `api/tests/integration/sheet_contract.py` (добавить SQL-хелперы; `row_key` и `assert_patch_rows_match_sheet` не трогать)
- Create: `api/tests/integration/test_upd_form_cells_subtree.py`
**Interfaces:**
- Consumes:
- фикстуры `headers` и `db_exec(sql: str, **params) -> list[Row]`;
- хелперы `_new_form`, `_add_line`, `_get_sheet`, `_patch_cell`, `_qs` из `test_upd_form_cells_full_rows.py` — импортировать оттуда, не копировать.
- Produces (в `sheet_contract.py`):
- `Row4 = tuple[str, int, int, str]` — `(row_type, depth, sort_order, data::text)`;
- `strip_keys(sections: list[str] | None) -> list[str]` — тот же список из 22 ключей, что `v_strip` в 0050;
- `sheet_kind(sheet: str) -> str` — `'sat'` для AHR_RENT/AHR_UTILITY/AHR_SECURITY, `'limit'`, `'otch9f'`, иначе `'tree'`;
- `reference_affected_rows(db_exec, form_id, sheet, ids, direction=None, sections=None, strip=True) -> list[Row4]`;
- `affected_rows(db_exec, form_id, sheet, ids, direction=None, sections=None) -> list[Row4]`;
- `focus_builder_rows(db_exec, form_type, form_id, sheet, ids, direction=None, sections=None) -> list[Row4]`;
- `assert_rows_equal(actual: list[Row4], expected: list[Row4], label: str) -> None` — при расхождении сообщение называет первую отличающуюся строку и длины списков.
- [ ] **Step 1: Хелперы в `sheet_contract.py`**
Эталон — фильтр 0050 поверх полного `v_form_view`. Это оракул: запрос задаётся буквально, параметры приводятся явно (asyncpg).
```python
_REF_FROM = """
SELECT v.row_type, v.depth, v.sort_order, (v.data - CAST(:strip AS text[]))::text
FROM v3.v_form_view(:f, :s, CAST(:sec AS text[]), CAST(:d AS varchar)) v
WHERE {where}
ORDER BY v.sort_order"""
_REF_WHERE = {
"sat": "v.row_type = 'INPUT' AND (v.data->>'id')::int = ANY(CAST(:ids AS int[]))",
"limit": "(v.data->>'id')::int = ANY(v3._limit_template_path_set(CAST(:ids AS int[])))",
"otch9f": """EXISTS (SELECT 1 FROM v3.fixed_asset_report far
WHERE far.id = ANY(CAST(:ids AS int[]))
AND far.expense_item_id = (v.data->>'expense_item_id')::int
AND far.equipment_name IS NOT DISTINCT FROM (v.data->>'equipment_name'))""",
"tree": """(v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM','SUB_SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::int = ANY(v3._expense_item_path_set(
ARRAY(SELECT DISTINCT bl.expense_item_id FROM v3.budget_line bl
WHERE bl.id = ANY(CAST(:ids AS int[]))))))
OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::int = ANY(CAST(:ids AS int[])))""",
}
```
- `affected_rows` — `SELECT r.row_type, r.depth, r.sort_order, r.data::text FROM v3._form_affected_rows(:f, :s, CAST(:ids AS int[]), CAST(:d AS varchar), CAST(:sec AS text[])) r ORDER BY r.sort_order`.
- `focus_builder_rows`:
- для FORM_2 — `v3.v_form2_view(:f, :s, CAST(:sec AS text[]), CAST(:ids AS int[]))`;
- для FORM_1 — `v3.v_form1_view(:f, :s, CAST(:sec AS text[]), CAST(:d AS varchar), CAST(:ids AS int[]))`;
- результат `ORDER BY sort_order`; сравнивается с `reference_affected_rows(..., strip=False)`.
- [ ] **Step 2: Тесты в `test_upd_form_cells_subtree.py`**
Годы новых форм — 2041–2060, у каждого теста свой. Тесты:
1. `test_affected_rows_match_reference[path, case]` — `path ∈ {"affected", "builder"}`. Случаи `case`:
- FORM_1: AHR/Support, AHR/Development, CAP/Support, OPER/`None`;
- FORM_2: AHR, CAP, OPER, AHR_RENT, AHR_UTILITY, AHR_SECURITY, AHR_LIMIT, OTCH9F;
- отдельно FORM_2/AHR с `sections=['plan']`.
Данные листов с деревом:
- новая форма;
- строки на первой и последней листовой статье по `mv_expense_item_tree.path` среди привязанных к типу формы;
- две строки-сестры с общим родителем;
- строка в другом ROOT;
- у всех строк `plan.q1` (через PATCH), у одной ещё `header.justification`;
- для FORM_1/AHR/Support дополнительно строка с `direction=Development` (Review Focus 2).
Данные остальных листов:
- сателлиты: форма 4, по 3 строки через `POST .../line` (`vsp_id`, `contract_number`; обязательные поля UTILITY и SECURITY взять из ветки `v3.add_budget_line`), PATCH `plan.q1`;
- AHR_LIMIT: форма 4, PATCH `qty_q1` у LEAF 13 и ещё у двух LEAF;
- OTCH9F: форма 4, `INSERT` через `db_exec` в `v3.fixed_asset_report` — та же раскладка, что в `snap_seed.sql` (Task 1), включая `equipment_name IS NULL` (Review Focus 3).
Проверки: для **каждой** строки листа по отдельности (id INPUT-строк из GET; для AHR_LIMIT — все LEAF, для сателлитов — все `data.id`) `assert_rows_equal(actual, reference, label=f"{case} id={i}")`. Для `path="affected"` фактический результат — `affected_rows`, для `path="builder"` — `focus_builder_rows` при `strip=False`. Дополнительно проверяется пакет `[первая, последняя, сестра, строка другого ROOT]` (для FORM_1/AHR/Support — плюс строка Development) и пакет с повтором id.
2. `test_edit_path_does_not_call_v_form_view[case]` — случаи FORM_1/AHR/Support, FORM_2/AHR, AHR_RENT, AHR_LIMIT, OTCH9F и `delta`.
- Если `SELECT rolsuper FROM pg_roles WHERE rolname = current_user` ложно — `pytest.skip("track_functions требует суперпользователя")`.
- Иначе: `SET LOCAL track_functions = 'all'`, затем `n0 = COALESCE(pg_stat_get_xact_function_calls(CAST('v3.v_form_view(integer,character varying,text[],character varying,integer)' AS regprocedure)), 0)`.
- Вызвать `_form_affected_rows` (для `delta` — `get_form2_ahr_satellite_delta(4, 'AHR_RENT', <id>)`).
- `assert n1 - n0 == 0`.
3. `test_empty_focus_returns_nothing` — 0 строк у каждого из вызовов:
- `v3.v_form2_view(4, s, NULL, '{}')` для s ∈ {AHR, CAP, OPER, AHR_RENT, AHR_UTILITY, AHR_SECURITY, AHR_LIMIT, OTCH9F};
- `v3.v_form1_view(1, 'AHR', NULL, 'Support', '{}')`;
- `v3._form_affected_rows(4, 'AHR', '{}')`;
- `v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', -1)`.
4. `test_patch_cells_two_branches_matches_get` — `PATCH /cells` по строкам двух ROOT формы FORM_2/AHR. `assert_patch_rows_match_sheet`; в ответе обе INPUT; ключи `row_key` без дублей.
5. `test_patch_after_add_and_delete_matches_get[FORM_2/AHR, FORM_1/AHR/Support]` — добавить 5 строк, удалить третью сверху (`DELETE .../line/{id}`), сделать PATCH строки ниже неё. `assert_patch_rows_match_sheet(patch, GET)`; у каждой строки ответа `sort_order` совпадает с `sort_order` строки GET с тем же `line_id` (для INPUT) или `header.expense_item_id` (для узлов).
6. `test_patch_form1_development_matches_get` — PATCH на FORM_1/AHR с `direction=Development`, `assert_patch_rows_match_sheet` с GET того же направления.
7. `test_patch_otch9f_returns_all_months_of_equipment` — PATCH месячного поступления (колонку взять из ветки OTCH9F `_apply_form_cell`) у строки `'E1'` месяца 1. Ответ = все строки `(статья, 'E1')`, остатки пересчитаны, `assert_patch_rows_match_sheet` с GET OTCH9F.
8. `test_sort_key_tie_node_before_line` — новая форма FORM_2.
- Найти статью AHR глубины 3, у которой есть дочерняя статья глубины 4, и обе привязаны к FORM_2; id дочерней не занят в `budget_line`. Если такой нет — `skip` с причиной.
- `INSERT INTO v3.budget_line (id, budget_form_id, expense_item_id, name, …) VALUES (<id дочерней>, …)` через `db_exec`, последовательность не трогать.
- Проверки: два GET дают одинаковый порядок; узел дочерней статьи стоит непосредственно перед этой строкой INPUT; PATCH `plan.q1` этой строки совпадает с GET (`assert_patch_rows_match_sheet`).
9. `test_ahr_delta_matches_get_after_rent_edit` — форма 4: POST строки AHR_RENT, PATCH `plan.q1 = 100`. Для `SELECT row_type, depth, sort_order, data::text FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', :id)` каждая строка равна строке с тем же `sort_order` из `v3.v_form_view(4, 'AHR', NULL, 'Support')` (тоже `data::text`). Среди строк есть INPUT с `plan.q1 = 100`.
10. `test_ahr_delta_includes_sub_sub_item_for_depth4_limit` — форма 4.
- Найти LEAF шаблона, у которого `expense_item_code` равен `num_group_id` статьи AHR/Support глубины 4. Ожидается LEAF 13, код `204620030`. Если такого нет — `skip`.
- PATCH `qty_q1 = 4` этого LEAF на AHR_LIMIT (синхронизация создаёт строку AHR).
- Дельта `(4, 'AHR_LIMIT', <lt.id>)` непуста и построчно совпадает с GET AHR, как в п. 9. В ней есть строка `SUB_SUB_ITEM` с `header.expense_item_id` статьи глубины 4.
- [ ] **Step 3: Убедиться, что тесты красные по правильной причине**
Run: `$A/wait_lock.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py -q -p no:cacheprovider -W ignore`
Expected на 0050:
- **FAIL**: все `path=builder` (`function v3.v_form2_view(integer, …, integer[]) does not exist` или `v3.v_form1_view … does not exist`); `test_edit_path_does_not_call_v_form_view` (`n1 - n0 == 1`), если он не skip; `test_empty_focus_returns_nothing` (нет сигнатуры); `test_ahr_delta_includes_sub_sub_item_for_depth4_limit` (нет SUB_SUB_ITEM).
- **PASS** (характеризация 0050, подтверждает эталон): все `path=affected`, тесты 4–7 и 9.
- Тест 8 до D6 может пройти или упасть случайно, это нормально.
Если какой-то `path=affected` падает, ошибка в эталоне или в данных: исправить тест, а не трогать SQL.
- [ ] **Step 4: Коммит**
```bash
git add api/tests/integration/sheet_contract.py api/tests/integration/test_upd_form_cells_subtree.py
git commit -m "test(sheet): эквивалентность ответа на правку и полной сборки — красные тесты (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Миграция 0051 и FORM_2 AHR/CAP/OPER — фокус и `_sort_rank`
**Files:**
- Create: `api/alembic/versions/0051_upd_form_cells_subtree.py`
- Create: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql`
**Interfaces:**
- Consumes: тела `v_form2_sheet_sections`, `v_form2_sheet_jsonb` и `v_form2_view` из `before-0051.sql`.
- Produces:
- `v3.v_form2_sheet_sections(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL, p_focus_lines integer[] DEFAULT NULL)` — `RETURNS TABLE(<прежние колонки до _sort_path включительно>, _sort_rank bigint)`, plpgsql, STABLE;
- `v3.v_form2_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL, p_focus_lines integer[] DEFAULT NULL)` — `RETURNS TABLE(<прежние>, _sort_rank bigint)`, LANGUAGE sql, проекция с передачей `p_focus_lines`;
- `v3.v_form2_view(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL, p_focus_lines integer[] DEFAULT NULL)` — `RETURNS TABLE(row_type varchar, depth integer, sort_order bigint, data jsonb)`. Ветка AHR/CAP/OPER: `sort_order = j._sort_rank`, `ORDER BY j._sort_rank`. Остальные ветки в этой задаче — без изменений, тело из дампа.
- [ ] **Step 1: Обёртка миграции**
```python
from sql_migration import run_sql_file_migration
revision = "0051"
down_revision = "0050"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0051_upd_form_cells_subtree.sql")
def downgrade() -> None:
pass
```
- [ ] **Step 2: SQL — DROP и три функции FORM_2**
Файл начинается с комментария со ссылкой на AURORA-1850 и change. Затем:
```sql
DROP FUNCTION v3.v_form2_view(integer, character varying, text[]);
DROP FUNCTION v3.v_form2_sheet_jsonb(integer, character varying, text[]);
DROP FUNCTION v3.v_form2_sheet_sections(integer, character varying, text[]);
```
(DROP FORM_1 добавит Task 5 сразу после этих строк.) Затем `CREATE FUNCTION` в порядке sections → jsonb → view. Тела копируются из дампа и правятся так (D1, D2, D6):
- в DECLARE считать `v_focus_nodes int[]` и `v_focus_eids int[]` (объединение `desc_ids` узлов фокуса из `v3.mv_expense_item_tree`), только если `p_focus_lines IS NOT NULL`. При пустом фокусе — `'{}'`, а не NULL;
- `tw` и `page` остаются полными: из них строится скелет. Рядом появляются отфильтрованные `tw_f` (`p_focus_lines IS NULL OR id = ANY(v_focus_nodes)`) и `page_f` (`p_focus_lines IS NULL OR lid = ANY(p_focus_lines)`);
- `jp..jq4`, `input_base` и `final_input` строятся по `page_f`;
- `agg` и `aq_q` ограничиваются `p_focus_lines IS NULL OR eid = ANY(v_focus_eids)`;
- `tw_agg` и `tw_aq1..4` строятся по `tw_f`;
- скелет `sk` — узлы `tw` (`_sort = path`, `line_id = NULL`) ∪ строки `page ⋈ tw` (`_sort = tree_path || lid`, `line_id = lid`), ранг `ROW_NUMBER() OVER (ORDER BY _sort, line_id NULLS FIRST)`. Вывод соединяется со скелетом по ключу узла или строки;
- итоговый `ORDER BY` — по `_sort_rank`.
Конкретная раскладка CTE остаётся за исполнителем (design.md, «Оставлено исполнителю»). Единственное ограничение: никакой ветки `IF p_focus_lines IS NULL THEN <копия запроса>` (D1в).
- [ ] **Step 3: Применить и прогнать тесты FORM_2 AHR/CAP/OPER**
Run:
```bash
$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py tests/integration/test_upd_form_cells_full_rows.py -q -p no:cacheprovider -W ignore -rf
```
Expected:
- PASS: `test_affected_rows_match_reference` для FORM_2 AHR/CAP/OPER в обоих `path` и с `sections=['plan']`; все `path=affected`; весь `test_upd_form_cells_full_rows.py`.
- Ещё красные (их закроют Task 4–7), список `-rf` должен состоять только из них: `path=builder` для сателлитов, LIMIT, OTCH9F и FORM_1; `test_empty_focus_returns_nothing`; тест «без полной сборки»; дельта SUB_SUB_ITEM; JIT ещё не написан.
- [ ] **Step 4: Снимок GET**
Run: `$A/snap.sh after && diff $A/get-before.tsv $A/get-after.tsv`
Expected: пустой diff. Допустимы только перестановки строк с совпадающим ключом (D6). Каждую такую перестановку перечислить в отчёте задачи с обоснованием. Любое другое расхождение — **стоп и эскалация**.
- [ ] **Step 5: Коммит**
```bash
git add api/alembic/versions/0051_upd_form_cells_subtree.py api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): фокус и _sort_rank в построителях FORM_2 AHR/CAP/OPER (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: FORM_2 — сателлиты, AHR_LIMIT, OTCH9F с фокусом в `v_form2_view`
**Files:**
- Modify: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql` (тело `v_form2_view`)
**Interfaces:**
- Consumes: `v_form2_view(..., p_focus_lines)` из Task 3.
- Produces: та же сигнатура. Для листов AHR_RENT/AHR_UTILITY/AHR_SECURITY `p_focus_lines` — id деталей, для AHR_LIMIT — id `limit_template`, для OTCH9F — id `fixed_asset_report`.
- [ ] **Step 1: Сателлиты (D4)**
Ранг — `ROW_NUMBER() OVER (ORDER BY <detail>.id)` по скелету без jsonb. **В скелете тот же набор строк, что в GET**: те же `JOIN v3.budget_line` и `JOIN v3.vsp` и тот же `WHERE` (Review Focus 4). Соединение с `vsp` по первичному ключу дешёвое. Убирать его можно только если доказано, что оно не фильтрует строки: `vsp_id NOT NULL` и есть FK. Затем фильтр `p_focus_lines IS NULL OR <detail>.id = ANY(p_focus_lines)`, и jsonb строится только по отобранным строкам.
- [ ] **Step 2: AHR_LIMIT**
Вывод фильтруется по `p_focus_lines IS NULL OR lt.id = ANY(v3._limit_template_path_set(p_focus_lines))`; `'{}'` даёт NULL и, значит, 0 строк. `sort_order = lt.sort_order`. Агрегат `tree`/`leaf_amt`/`agg` можно оставить полным. Вызовы `v3._form2_ahr_limit_amount(...)` из дампа сохраняются буквально.
- [ ] **Step 3: OTCH9F**
`base` ограничивается разделами фокуса:
```sql
AND (p_focus_lines IS NULL OR EXISTS (
SELECT 1 FROM v3.fixed_asset_report ff
WHERE ff.id = ANY(p_focus_lines)
AND ff.expense_item_id IS NOT DISTINCT FROM far.expense_item_id
AND ff.equipment_name IS NOT DISTINCT FROM far.equipment_name))
```
Ранг — отдельный скелет по всем строкам формы с тем же `JOIN v3.expense_item`: `ROW_NUMBER() OVER (ORDER BY expense_item_id, month, id)` (D6). `sort_order` берётся из скелета соединением по `id`.
- [ ] **Step 4: Тесты**
Run: `$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py tests/integration/test_upd_form_cells_full_rows.py -q -p no:cacheprovider -W ignore`
Expected: PASS — `test_affected_rows_match_reference` для AHR_RENT, AHR_UTILITY, AHR_SECURITY, AHR_LIMIT и OTCH9F в обоих `path`, а также `test_patch_otch9f_returns_all_months_of_equipment`. Красными остаются только случаи, которые закрывают Task 5–7.
- [ ] **Step 5: Снимок GET**
Run: `$A/snap.sh after && diff $A/get-before.tsv $A/get-after.tsv`
Expected: diff пуст. Для OTCH9F допустимы только перестановки при равных `(expense_item_id, month)`, если они есть (D6) — перечислить. Иначе стоп.
- [ ] **Step 6: Коммит**
```bash
git add api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): фокус сателлитов, AHR_LIMIT и OTCH9F в v_form2_view (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: FORM_1 — фокус, `_sort_rank`, новая `v_form1_view`, `v_form_view`
**Files:**
- Modify: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql`
**Interfaces:**
- Consumes: тела `v_form1_sheet_sections`, `v_form1_sheet_jsonb` и `v_form_view` из `before-0051.sql`.
- Produces:
- `v3.v_form1_sheet_sections(p_form_id integer, p_sheet character varying, p_direction character varying DEFAULT NULL, p_sections text[] DEFAULT NULL, p_focus_lines integer[] DEFAULT NULL)` — `RETURNS TABLE(<прежние>, _sort_rank bigint)`;
- `v3.v_form1_sheet_jsonb(<те же параметры>)` — `RETURNS TABLE(<прежние>, _sort_rank bigint)`, LANGUAGE sql;
- `v3.v_form1_view(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL, p_direction character varying DEFAULT NULL, p_focus_lines integer[] DEFAULT NULL)` — `RETURNS TABLE(row_type varchar, depth integer, sort_order bigint, data jsonb)`, plpgsql, STABLE. Принимает только AHR/CAP/OPER, на остальных листах — `RAISE EXCEPTION 'v_form1_view: unsupported sheet %'`. Тело — `jsonb_build_object` из 16 ключей ветки FORM_1 `v_form_view` в прежнем порядке; `sort_order = j._sort_rank`;
- `v3.v_form_view` — `CREATE OR REPLACE`, сигнатура прежняя. Ветка FORM_1 AHR/CAP/OPER — `RETURN QUERY SELECT * FROM v3.v_form1_view(p_form_id, p_sheet, p_sections, p_direction, NULL); RETURN;`. Остальное без изменений.
- [ ] **Step 1: DROP FORM_1**
Сразу после трёх DROP FORM_2:
```sql
DROP FUNCTION v3.v_form1_sheet_jsonb(integer, character varying, character varying, text[]);
DROP FUNCTION v3.v_form1_sheet_sections(integer, character varying, character varying, text[]);
```
- [ ] **Step 2: Построители FORM_1**
Правки те же, что в Task 3, с отличием (контекст design.md): `page` и все агрегатные CTE (`agg`, `aq1..aq4`, `atot`) фильтруются по `direction` так же, как в дампе; `tw` — нет. Узлы фокуса берутся из `budget_line` **без** фильтра `direction`, INPUT фокуса — `page_f` (с фильтром). Скелет — `tw` ∪ `page ⋈ tw` с тем же фильтром направления.
- [ ] **Step 3: `v_form1_view` и `v_form_view`**
Порядок `CREATE` по D9: `v_form1_sheet_sections` и `v_form1_sheet_jsonb` вместе с остальными sections и jsonb, затем `v_form2_view`, затем `v_form1_view`, затем `v_form_view`.
- [ ] **Step 4: Тесты**
Run: `$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py tests/integration/test_upd_form_cells_full_rows.py -q -p no:cacheprovider -W ignore`
Expected: PASS — все случаи FORM_1 в обоих `path`, включая строку Development в пакете Support; `test_patch_form1_development_matches_get`; `test_patch_after_add_and_delete_matches_get[FORM_1…]`; `test_empty_focus_returns_nothing` (теперь есть обе сигнатуры). Красными остаются только тест «без полной сборки» и тест дельты с SUB_SUB_ITEM.
- [ ] **Step 5: Снимок GET**
Run: `$A/snap.sh after && diff $A/get-before.tsv $A/get-after.tsv` → пусто, включая FORM_1 SMETA (`v_form1_smeta` зовёт `v_form1_sheet_sections` с 4 аргументами). Иначе стоп.
- [ ] **Step 6: Коммит**
```bash
git add api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): фокус и _sort_rank в построителях FORM_1, новая v_form1_view (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: `_form_affected_rows` без полной сборки
**Files:**
- Modify: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql` (добавить `CREATE OR REPLACE FUNCTION v3._form_affected_rows` после `v_form_view`)
**Interfaces:**
- Consumes: `v_form2_view(..., p_focus_lines)`, `v_form1_view(..., p_focus_lines)`; тело 0050 из `before-0051.sql`.
- Produces: `v3._form_affected_rows(p_form_id integer, p_sheet character varying, p_ids integer[], p_direction character varying DEFAULT NULL, p_sections text[] DEFAULT NULL)`, `RETURNS TABLE(row_type varchar, depth integer, sort_order bigint, data jsonb)`, plpgsql, **STABLE**.
- [ ] **Step 1: Переписать ветки (D5)**
- `v_strip` — как в 0050.
- FORM_1, листы AHR/CAP/OPER → `SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip FROM v3.v_form1_view(p_form_id, p_sheet, p_sections, p_direction, COALESCE(p_ids, '{}')) v ORDER BY v.sort_order`.
- FORM_2, листы AHR/CAP/OPER/AHR_RENT/AHR_UTILITY/AHR_SECURITY/AHR_LIMIT/OTCH9F → то же через `v3.v_form2_view(p_form_id, p_sheet, p_sections, COALESCE(p_ids, '{}'))`.
- Ветка FORM_4 — буквально как в 0050.
- Остальные сочетания (SMETA и прочие) — прежний путь 0050 через `v_form_view`.
Фильтров по `_expense_item_path_set` и `_limit_template_path_set` в FORM_1 и FORM_2 больше нет: состав ответа задаёт построитель.
- [ ] **Step 2: Тесты**
Run: `$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py tests/integration/test_upd_form_cells_full_rows.py -q -p no:cacheprovider -W ignore`
Expected:
- PASS: все `path=affected`; `test_edit_path_does_not_call_v_form_view` для всех случаев, кроме `delta` (или skip — тогда наблюдение `O-n` в ledger и вопрос контроллеру); все HTTP-тесты; `test_form4_returns_only_own_program_and_project`; `test_bad_column_in_batch_fails_and_applies_nothing`.
- Красными остаются только тесты дельты (п. 2/delta и п. 10).
- [ ] **Step 3: Коммит**
```bash
git add api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): _form_affected_rows собирает только затронутое поддерево (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Дельта АХР через `_form_affected_rows`
**Files:**
- Modify: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql` (последним оператором файла)
**Interfaces:**
- Consumes: `_form_affected_rows` из Task 6; тело `get_form2_ahr_satellite_delta` из `before-0051.sql`.
- Produces: `v3.get_form2_ahr_satellite_delta(p_form_id integer, p_source_sheet character varying, p_source_row_id integer)` — сигнатура, `RETURNS TABLE`, `LANGUAGE sql` и `STABLE` прежние.
- [ ] **Step 1: Тело**
`CREATE OR REPLACE` с телом из дампа, в котором:
- CTE `target_lines` скопирован **символ в символ**, включая сопоставление `(ei.depth = 3 AND ei.item_id = lt.expense_item_code) OR ei.num_group_id = lt.expense_item_code`;
- CTE `target_path` удалён;
- финальный SELECT заменён на
```sql
SELECT r.row_type, r.depth, r.sort_order, r.data
FROM v3._form_affected_rows(
p_form_id, 'AHR',
COALESCE((SELECT array_agg(tl.line_id) FROM target_lines tl), '{}'),
'Support', NULL) r
ORDER BY r.sort_order;
```
- [ ] **Step 2: Тесты**
Run: `$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py -q -p no:cacheprovider -W ignore`
Expected: PASS (или skip с указанной причиной) для всего файла, включая `delta`, `test_ahr_delta_matches_get_after_rent_edit`, `test_ahr_delta_includes_sub_sub_item_for_depth4_limit` и пустую дельту в `test_empty_focus_returns_nothing`.
- [ ] **Step 3: Коммит**
```bash
git add api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): дельта АХР через _form_affected_rows, с предками SUB_SUB_ITEM (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: `SET jit = off` на построителях и пути правки (D7)
**Files:**
- Modify: `api/tests/integration/test_upd_form_cells_subtree.py`
- Modify: `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql`
**Interfaces:**
- Consumes: функции из Task 3–7.
- Produces: `proconfig` содержит `jit=off` у `v3.v_form2_sheet_sections`, `v3.v_form1_sheet_sections`, `v3._form_affected_rows` и `v3.get_form2_ahr_satellite_delta`.
- [ ] **Step 1: Падающий тест**
```python
JIT_OFF_FUNCS = ("v_form2_sheet_sections", "v_form1_sheet_sections",
"_form_affected_rows", "get_form2_ahr_satellite_delta")
def test_jit_off_on_builders_and_edit_path(db_exec):
rows = db_exec(
"""SELECT p.proname, COALESCE(p.proconfig, '{}') FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'v3' AND p.proname = ANY(CAST(:names AS text[]))""",
names=list(JIT_OFF_FUNCS))
assert {r[0] for r in rows} == set(JIT_OFF_FUNCS)
missing = [r[0] for r in rows if "jit=off" not in r[1]]
assert not missing, f"нет SET jit = off: {missing}"
```
- [ ] **Step 2: Убедиться, что тест падает**
Run: `$A/wait_lock.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py::test_jit_off_on_builders_and_edit_path -q -p no:cacheprovider -W ignore`
Expected: FAIL, `нет SET jit = off: [...4 имени]`.
- [ ] **Step 3: Атрибуты**
В заголовок каждой из 4 функций в 0051 добавить строку ` SET jit TO 'off'` (после `STABLE`, перед `AS $function$`). На другие функции атрибут не ставить.
- [ ] **Step 4: Тесты**
Run: `$A/reset_db.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree.py tests/integration/test_upd_form_cells_full_rows.py -q -p no:cacheprovider -W ignore`
Expected: всё PASS (или skip с причиной).
- [ ] **Step 5: Коммит**
```bash
git add api/tests/integration/test_upd_form_cells_subtree.py api/alembic/versions/sql/0051_upd_form_cells_subtree.sql
git commit -m "perf(sql): jit = off на построителях и пути правки (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Защита правки 0037 (design.md, «Тестирование», п. 7)
**Files:**
- Create: `api/tests/integration/test_upd_form_cells_subtree_0037_guard.py`
**Interfaces:**
- Consumes: фикстуры `headers` и `db_exec`, хелперы `_get_sheet` и `_patch_cell` (импорт из `test_upd_form_cells_full_rows.py`).
- [ ] **Step 1: Тесты**
```python
from decimal import Decimal
def test_v_form2_view_keeps_0037_limit_formula(client, headers, db_exec):
# LEAF «- принтеры», limit_template.id = 13, код 204620030 (0013_form2_limit.sql:1707)
_patch_cell(client, headers, 4, "AHR_LIMIT", 13, "qty_q1", 4)
row = next(r for r in _get_sheet(client, headers, 4, "AHR_LIMIT") if r["data"]["id"] == 13)
assert Decimal(str(row["data"]["amount_q1"])) == Decimal(4) * Decimal("0.57") * 10900 / 4
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
s="v3.v_form2_view(integer,character varying,text[],integer[])")[0][0]
assert "_form2_ahr_limit_amount" in src
OLD_DEPTH3_MATCHER = re.compile(r"ei\.depth\s*=\s*3\s+AND\s*\(") # вид до 0037
def test_ahr_delta_keeps_0037_num_group_matching(client, headers, db_exec):
found = db_exec(
"""SELECT lt.id FROM v3.limit_template lt
JOIN v3.expense_item ei ON ei.num_group_id = lt.expense_item_code
AND ei.sheet = 'AHR' AND ei.direction = 'Support' AND ei.depth = 4
WHERE lt.row_type = 'LEAF' ORDER BY lt.id LIMIT 1""")
if not found:
pytest.skip("нет LEAF шаблона с кодом = num_group_id статьи AHR глубины 4")
lt_id = found[0][0]
_patch_cell(client, headers, 4, "AHR_LIMIT", lt_id, "qty_q1", 4)
delta = db_exec("SELECT row_type FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_LIMIT', :i)", i=lt_id)
assert any(r[0] == "INPUT" for r in delta), "дельта АХР пуста — откат сопоставления 0037"
src = db_exec("SELECT pg_get_functiondef(CAST(:s AS regprocedure))",
s="v3.get_form2_ahr_satellite_delta(integer,character varying,integer)")[0][0]
assert "ei.num_group_id = lt.expense_item_code" in src
assert not OLD_DEPTH3_MATCHER.search(src)
```
Импорты: `re`, `pytest`, `Decimal`. Сравнение `amount_q1` идёт через `Decimal`, чтобы масштаб numeric не мешал. Если значение в `data` — строка или число, привести через `str`.
- [ ] **Step 2: Прогон**
Run: `$A/wait_lock.sh && cd $W/api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_upd_form_cells_subtree_0037_guard.py -q -p no:cacheprovider -W ignore`
Expected: PASS. Это защитный тест: на 0051 он должен проходить сразу. Если FAIL — откат правки 0037, **стоп и эскалация** (правка тела вручную по дампу, затем снова тест).
- [ ] **Step 3: Проверка, что тест кусается (без БД)**
Строковые проверки обоих тестов применить к текстам функций **до** 0037: `get_form2_ahr_satellite_delta` — `api/alembic/versions/sql/0031_form2_ahr_satellite_sync.sql:784-855`, `v_form2_view` — `0033_form2_old_sort.sql:1-373`. Ожидается, что `OLD_DEPTH3_MATCHER.search` находит совпадение в тексте 0031, а `"_form2_ahr_limit_amount"` отсутствует в тексте 0033. Проверить однострочником `$PY/python -c "…"` и записать вывод в ledger.
- [ ] **Step 4: Коммит**
```bash
git add api/tests/integration/test_upd_form_cells_subtree_0037_guard.py
git commit -m "test(sheet): защита правки 0037 после 0051 (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: ARCHITECTURE.md, прогон с нуля, итоговый снимок GET
**Files:**
- Modify: `ARCHITECTURE.md` (абзац «Поток редактирования ячейки», строка 5)
- Modify: `openspec/changes/aurora-1850-upd-form-cells-subtree/design.md` (решения без обсуждения, если появились по ходу)
- [ ] **Step 1: ARCHITECTURE.md**
Последнее предложение абзаца заменить на:
> Выборку делает `v3._form_affected_rows` (миграции 0050, 0051). Для FORM_1 и FORM_2 она не собирает лист целиком: построители (`v_form1_view`/`v_form2_view` → `*_sheet_jsonb` → `*_sheet_sections`) вызываются с фокусом `p_focus_lines` (NULL — весь лист, `'{}'` — ничего) и считают только изменённые строки и узлы их путей. `sort_order` в обоих режимах берётся из скелета листа (`_sort_rank`), поэтому совпадает с GET. FORM_4 до AURORA-1846 собирается полностью. Фоновая дельта АХР после правки сателлита (`get_form2_ahr_satellite_delta`) идёт тем же путём.
- [ ] **Step 2: Полный прогон на чистой БД (вся цепочка миграций с нуля)**
Run:
```bash
$A/wait_lock.sh && docker exec perf-pg dropdb -U perf --if-exists dfip_1850 && docker exec perf-pg createdb -U perf dfip_1850 \
&& cd $W/api && DATABASE_URL=$DBURL $PY/pytest --init_db -q -p no:cacheprovider -W ignore
```
Expected: падают те же 3 исходных теста, новых падений нет. Число passed = базовое (Task 1) плюс новые тесты. Число skipped и причины skip выписать в ledger.
- [ ] **Step 3: Итоговый снимок GET**
Run: `$A/snap.sh after && diff $A/get-before.tsv $A/get-after.tsv` → пусто или только перечисленные перестановки D6.
- [ ] **Step 4: Ничего лишнего в диффе ветки**
Run: `git diff --stat origin/feat/215-aurora-1844-upd-form-cells-full-rows...HEAD -- api/src web perf`
Expected: пусто.
- [ ] **Step 5: Коммит и push**
```bash
git add ARCHITECTURE.md openspec/changes/aurora-1850-upd-form-cells-subtree/design.md
git commit -m "docs: ARCHITECTURE — фокусная сборка ответа на правку (AURORA-1850)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git push origin feat/235-aurora-1850-upd-form-cells-subtree
```
---
## Завершение (SDD-контроллер)
1. **Передать главному контроллеру проверку PG15.** Сообщить ветку и коммит и попросить прогнать `pytest --init_db` на `dfip-pg15:55434` (своя БД `dfip_1850`) — миграции с нуля до 0051 и весь набор. Исполнитель на PG15 не ходит. **Ждать ответа.** Падение миграции или новых тестов на PG15 — это нарушение корректности: стоп, правка, повторная передача.
2. **Финальное ревью всей ветки — модель opus** (superpowers:requesting-code-review). Бриф ревьюера:
- дифф `origin/feat/215-aurora-1844-upd-form-cells-full-rows...HEAD`;
- `before-0051.sql` как база для сравнения тел: diff каждой функции «до» и в 0051;
- Review Focus (5 пунктов выше), D1–D9, правило замка.
Находки `C-n`/`I-n` исправляются в ветке, `M-n` — по решению контроллера. После правок — повтор Task 10, Step 2.
3. **PR без цифр** (superpowers:finishing-a-development-branch):
```bash
tea pulls create --base feat/215-aurora-1844-upd-form-cells-full-rows \
--head feat/235-aurora-1850-upd-form-cells-subtree \
--title "AURORA-1850: правка ячейки пересчитывает только затронутое поддерево" \
--description "$(cat <файл описания>)"
```
Описание PR, по-русски:
- `Closes #235`, Jira AURORA-1850;
- «Что сделано»;
- «Тесты» — итог полного прогона на PG16 с оговоркой про 3 исходных падения и результат PG15 от контроллера;
- «Проверить руками» — правка на FORM_2/AHR и FORM_1/AHR у двух соавторов: сумма предка обновилась у второго, соседние строки не затронуты; правка аренды обновляет АХР;
- «Замер до/после — добавит контроллер после прогона на стенде» и сценарий из design.md, «Замер до/после», шаги 1–7;
- «Rulings I made» — список `R-n` из финального отчёта контроллера;
- таблица находок `| Маркер | Суть | Статус |`, где `deferred` выделены отдельно;
- последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
Не мержить.
4. **Точка ожидания: замеры главного контроллера** (design.md, «Замер до/после»; после основных ночных замеров). Исполнитель ничего не запускает на стенде и не трогает БД `perf`. Контроллер записывает цифры в PR.
- Шаги 3–4 замера (снимок GET, эквивалентность на данных стенда) разошлись — **стоп и эскалация** (корректность).
- Шаги 5–7 вышли за пороги — итерации только на пути правки, по порядку из Global Constraints («Стоп и итерации»). Каждая итерация — отдельный коммит в этой ветке, полный pytest и повторный замер контроллером. Недобор после шага 3 правила записывается в PR и уходит в follow-up.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-28

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,289 @@
# Design: правка ячейки пересчитывает только затронутое поддерево (AURORA-1850, #235)
Зачем это нужно — в proposal.md, раздел Why. Требования — в specs/sheet-cell-edit/spec.md.
## Context
Где мы сейчас: ветка стоит в стеке от `feat/215-aurora-1844-upd-form-cells-full-rows`, миграция 0050.
- Цепочка вызовов при правке такая. HTTP `PATCH .../cell(s)` или WS `cell_updated` попадает в `SheetRepository.update_cell(s)` (`api/src/repository/sheet_repository.py:70-116`). Оттуда вызывается `v3.upd_form_cell` (`0002_functions.sql:2478`, SQL-обёртка). Далее — `v3.upd_form_cells(..., p_user_id, ...)`: права, `computed_field`, синхронизация сателлитов (`0031_form2_ahr_satellite_sync.sql:904-987`). После неё — `v3.upd_form_cells` без пользователя (`0050_upd_form_cells_full_rows.sql`): проверка строк, `_apply_form_cell`, затем один вызов `v3._form_affected_rows(p_form_id, p_sheet, p_ids, p_direction, p_sections)`.
- `_form_affected_rows` (0050) во всех ветках собирает весь лист через `v3.v_form_view` и фильтрует результат. Фильтры по листам:
- сателлиты — `data.id ∈ p_ids`;
- AHR_LIMIT — путь по `limit_template` (`_limit_template_path_set`);
- OTCH9F — то же `(expense_item_id, equipment_name)`;
- FORM_4 — ROOT, GROUP по программе и ITEM по проекту;
- FORM_1 и FORM_2 AHR/CAP/OPER — агрегаты с `header.expense_item_id ∈ _expense_item_path_set(eid изменённых строк)` плюс INPUT с `line_id ∈ p_ids`;
- при `p_sections` ключи исключённых разделов вырезаются (`v_strip`).
- Сборка FORM_2 AHR/CAP/OPER идёт по цепочке `v_form_view` → `v_form2_view` (`0033:1-373`, переписана регуляркой в 0037) → `v_form2_sheet_jsonb` (`0015:2534-2804`, LANGUAGE sql, проекция строка в строку) → `v_form2_sheet_sections` (`0033:469-1155`, plpgsql). Внутри `v_form2_sheet_sections`:
- `tw` — все узлы дерева статей листа для типа формы;
- `page` — все строки формы на листе;
- `jp..jq4` и `input_base` — колонки INPUT;
- `agg` и `aq_q` — суммы по `expense_item_id` по всем строкам листа;
- `tw_agg` и `tw_aq1..4` — суммы узла: `LEFT JOIN agg ON eid = ANY(tw.desc_ids)`, причём `desc_ids` включает сам узел (`0001_initial_schema.py:1063-1065`);
- `_sort`: для INPUT это `tree_path || line_id`, для узла — `t.path`.
- `sort_order` вычисляется выше по цепочке: `ROW_NUMBER() OVER (ORDER BY _sort_path)` в `v_form2_view` (`0033:21`), для FORM_1 — в ветке `v_form_view` (`0015:1478-1507`). Набор ключей полного листа — все узлы `tw` плюс строки `page ⋈ tw`. Соединение внутреннее, поэтому строка статьи, не привязанной к типу формы, в лист не попадает.
- FORM_1 устроена так же: `v_form_view` (ветка AHR/CAP/OPER) → `v_form1_sheet_jsonb` (`0015:2268`) → `v_form1_sheet_sections` (`0033:1723-2470`). Отличие в том, что `page` и все агрегатные CTE фильтруются по `direction` (`bl.direction IS NULL OR = p_direction`), а `tw` — нет.
- Сателлитные листы, AHR_LIMIT и OTCH9F собираются прямо в `v_form2_view`. `sort_order` у них такой:
- RENT/UTILITY/SECURITY — `ROW_NUMBER` по `id`;
- AHR_LIMIT — `limit_template.sort_order`;
- OTCH9F — `ROW_NUMBER` по `(expense_item_id, month)`.
- `v3.get_form2_ahr_satellite_delta` (0031:784-855, переписана регуляркой в 0037) вызывается в фоне из `notify_ahr_about_satellite_change` (`websocket.py:798-903`). Она собирает `v_form_view(form, 'AHR', NULL, 'Support')` целиком и фильтрует ROOT/GROUP/ITEM/SUB_ITEM по пути, а SUB_SUB_ITEM не берёт. Результат уходит на лист AHR как `cell_updated` или `row_added`.
- Где тратится время (`perf/report-data/20261026-s1/explain-manual/summary.md`, `form2_ahr_tw.jit_off`; 2091 строка):
- тело `v_form2_sheet_sections` — 240 мс;
- основное — 4 × `tw ⋈ aq_q` вложенным циклом (546 узлов × 438 групп, около 100 мс);
- соединения `input_base` (около 60 мс);
- JIT по умолчанию добавляет ещё 210–230 мс (М12).
- Фронт ищет строку ответа по `(row_type, depth, sort_order)` (`cellUtils.js:76`; так же в ветке AURORA-1838). Поэтому неточный `sort_order` молча испортит чужую строку.
## Goals / Non-Goals
**Goals:**
- Правка FORM_1 и FORM_2 (все редактируемые листы) не собирает лист целиком. Ответ по составу и содержимому байт в байт равен тому, что дала бы полная сборка с фильтром из 0050.
- Один источник истины. Суммы, колонки INPUT и `sort_order` считаются тем же кодом, что и GET.
- Фоновое обновление АХР после правки сателлита не собирает AHR целиком.
**Non-Goals:**
- FORM_4: остаётся на полной сборке до слияния AURORA-1846.
- `add_budget_line` и `del_budget_line`: их ответы по-прежнему строятся полной сборкой.
- Ускорение GET листа.
- Инкрементальные или материализованные суммы.
- Пакетная обработка правок соединения (бывшая часть AURORA-1845).
- Кэш пользователя и формы на соединение (AURORA-1845).
- Изменения фронта.
## Решения (согласовано 28.10)
1. `v3._form_affected_rows` переписывается так, чтобы не собирать весь лист. Существующие построители (`v_form1_sheet_sections`, `v_form2_sheet_sections` и вызывающие их функции) получают параметр фокуса по строкам. NULL означает прежнее поведение. Параллельной копии нет. `sort_order` в ответе точный, то есть глобальный `ROW_NUMBER`.
2. Охват — только FORM_1 и FORM_2: AHR/CAP/OPER, сателлиты, AHR_LIMIT и OTCH9F (способ — по дизайну ниже). FORM_4 остаётся на полной сборке до AURORA-1846 (PR #232), критерий для FORM_4 переносится.
3. `get_form2_ahr_satellite_delta` переводится на тот же механизм. Тело берётся с мигрированной БД, с учётом 0037.
4. Корректность доказывает тест эквивалентности на базе `sheet_contract.py`: каждая строка ответа PATCH побайтно равна строке того же `sort_order` из полного GET. Покрываются FORM_1 и FORM_2 (все листы), первая и последняя строка, очистка, PATCH `/cells` по разным ветвям, `sort_order` после POST и DELETE line.
5. Критерий: mean `upd_form_cell` ≤ 50 мс на FORM_2/AHR и 0 вложенных полных сборок листа на правку. Вторая точка снимается после grow (2092 строки).
6. AURORA-1845 сужена до кэша на соединение. После 1850 ручной ввод не должен создавать очередь — это проверяется замером coauthor на rate 5.
7. Номер миграции — 0051, в стеке после 0050 из 1844. При rebase на 1831 и 1846 номер меняется.
8. JIT выключается на уровне наших функций в миграции (`CREATE FUNCTION … SET jit = off`): в построителях `v_form{1,2}_sheet_sections`, в `_form_affected_rows` и в дельте АХР (D7). Конфигурацию сервера Postgres не трогаем — это не зона команды (AURORA-1849).
9. Пороги производительности (≤ 50 мс, GET не больше +10 %, нет очереди на rate 5) — **не условие мержа**. PR вливается по зелёным тестам и доказанной корректности. Цифры замера записываются в PR, недобор оформляется follow-up-задачей. Корректность — **жёсткое условие**, и любое её нарушение означает стоп и эскалацию:
- эквивалентность ответа на правку и полного GET (тесты п. 1–6);
- совпадение снимка GET до и после миграции, кроме строк, порядок которых раньше был недетерминирован из-за совпадения ключа (D6);
- защита правки 0037 (тесты п. 7).
## Decisions
### D1. Параметр фокуса и его семантика
У `v_form2_sheet_sections`, `v_form2_sheet_jsonb`, `v_form2_view`, `v_form1_sheet_sections` и `v_form1_sheet_jsonb` последним параметром добавляется `p_focus_lines integer[] DEFAULT NULL`. В новой `v_form1_view` (D3) он тоже есть.
- `NULL` — полный лист, поведение прежнее.
- Непустой массив — только строки фокуса и узлы их путей.
- Пустой массив `'{}'` — **пустой результат**, ни в коем случае не полный лист. `array_agg` по пустому набору даёт NULL, поэтому вызывающие обязаны передавать `COALESCE(..., '{}')`. Это проверяется тестом (раздел «Тестирование», п. 6).
Смысл фокуса для листов AHR/CAP/OPER:
- **Узлы фокуса** — `_expense_item_path_set(eid строк фокуса)` ∩ `tw`. eid берутся из `budget_line` **без** фильтра `direction`, как в 0050: правка строки чужого направления в FORM_1 возвращает предков, но не саму строку.
- **INPUT фокуса** — строки фокуса, прошедшие обычный фильтр `page` (форма, лист, `direction`, соединение с `tw`).
- **Суммы.** `agg` и `aq_q` (у FORM_1 — `agg`, `aq1..aq4`, `atot`) ограничиваются строками с `expense_item_id ∈ ∪ desc_ids(узлы фокуса)`. Сумма узла определяется только его `desc_ids`, которые входят в это объединение, поэтому значение не меняется. `tw_agg` и `tw_aq*` строятся только по узлам фокуса.
- `jp..jq4`, `input_base` и `final_input` строятся только по INPUT фокуса.
Отвергнутые варианты:
- (а) Отдельная «лёгкая» функция сумм, копия формул. Отвергнута решением человека: два источника истины.
- (б) Фильтр поверх полного `v_form_view`. Это текущее состояние: plpgsql-функция не принимает `WHERE` внутрь.
- (в) Ветвление `IF p_focus_lines IS NULL THEN <запрос> ELSE <копия запроса>` внутри построителя. Это та же копия на 700 строк.
### D2. sort_order считает построитель, по «скелету»
Построители `*_sheet_sections` и `*_sheet_jsonb` получают выходную колонку `_sort_rank bigint`. Её значение — `ROW_NUMBER() OVER (ORDER BY _sort, <tie-break D6>)` по **скелету** листа. Скелет — ключи всех узлов `tw` и всех строк `page ⋈ tw` без jsonb и без сумм: ровно тот набор ключей, который даёт полный лист. Ранг считается одинаково в обоих режимах, и строки вывода получают его соединением по ключу.
`v_form2_view` и `v_form1_view` берут `sort_order` из `_sort_rank` и больше не считают собственный `ROW_NUMBER`.
Скелет на 2092 строки стоит единицы миллисекунд: сортировка около 2,7 тыс. `int[]`. Это лишняя работа и для GET, но зато порядок считается одним кодом в обоих режимах.
Отвергнутые варианты:
- (а) `ROW_NUMBER` в вызывающих функциях, как сейчас. В режиме фокуса он нумерует подмножество.
- (б) Отдельный запрос `count(*) ключей < k` в `_form_affected_rows`. Он дублирует правила набора ключей (`direction`, соединение `page ⋈ tw`).
### D3. FORM_1: новая `v3.v_form1_view`
Ветка FORM_1 AHR/CAP/OPER в `v_form_view` (`0015:1478-1507`, `jsonb_build_object` из 16 ключей) выносится в `v3.v_form1_view(p_form_id, p_sheet, p_sections, p_direction, p_focus_lines)`. Она симметрична `v_form2_view`. `v_form_view` вызывает её с фокусом NULL, `_form_affected_rows` — с `p_ids`. Сигнатура `v_form_view` не меняется (`CREATE OR REPLACE`).
Отвергнутый вариант: `_form_affected_rows` сама собирает jsonb поверх `v_form1_sheet_jsonb`. Тогда у проекции строки было бы два места.
### D4. Сателлиты, AHR_LIMIT, OTCH9F — внутри `v_form2_view` с тем же параметром
- **AHR_RENT, AHR_UTILITY, AHR_SECURITY.** Ранг — `ROW_NUMBER() OVER (ORDER BY id)` по всем строкам формы на этом листе: только `id`, без соединения с `vsp` и без jsonb. Затем фильтр `id = ANY(p_focus_lines)`, и jsonb строится только для отобранных строк. Здесь `p_focus_lines` — id деталей (как `p_ids` в 0050).
- **AHR_LIMIT.** Вывод фильтруется по `_limit_template_path_set(p_focus_lines)`. `sort_order` = `lt.sort_order` — статичный, от фокуса не зависит. Агрегат `tree`/`leaf_amt`/`agg` остаётся по всему шаблону: шаблон общий и его размер не зависит от числа строк формы. Сужать его можно, но не обязательно (на усмотрение исполнителя). Формула 0037 (`v3._form2_ahr_limit_amount`) сохраняется.
- **OTCH9F.** `base` ограничивается разделами `(expense_item_id, equipment_name)` строк фокуса. Окна разделяются по `(budget_form_id, expense_item_id, equipment_name)`, поэтому разделы целые и остатки те же. Ранг — `ROW_NUMBER() OVER (ORDER BY expense_item_id, month, id)` по всем строкам формы (D6) на лёгком скелете с тем же `JOIN expense_item`.
- **SMETA и прочие листы** — без изменений: фокус игнорируется, а `_form_affected_rows` для них по-прежнему зовёт полный путь (такие листы не редактируются).
### D5. `_form_affected_rows` и `get_form2_ahr_satellite_delta`
- `_form_affected_rows`, ветки FORM_1 и FORM_2:
- AHR/CAP/OPER → `v_form1_view` или `v_form2_view` с `p_focus_lines = p_ids`;
- сателлиты, AHR_LIMIT, OTCH9F → `v_form2_view` с фокусом.
Правила состава ответа (0050) при этом выполняет сам построитель. `v_strip` и `ORDER BY sort_order` остаются. Ветка FORM_4 без изменений. Функция помечается `STABLE`: только чтение, а её вызывает STABLE-функция дельты.
- `get_form2_ahr_satellite_delta`: CTE `target_lines` сохраняется **в том виде, как он стоит в БД после 0037**, вместе с сопоставлением `(ei.depth = 3 AND ei.item_id = …) OR ei.num_group_id = …`. Финальный `SELECT` заменяется на `SELECT * FROM v3._form_affected_rows(p_form_id, 'AHR', COALESCE((SELECT array_agg(line_id) FROM target_lines), '{}'), 'Support', NULL)`. CTE `target_path` больше не нужен. Сигнатура и `LANGUAGE sql STABLE` прежние, Python не меняется.
- Изменение поведения дельты: теперь в неё входят предки уровня SUB_SUB_ITEM, а раньше фильтр их пропускал (0031:843). После 0033 у AHR есть статьи глубины 4, и их агрегат у соавторов оставался старым. Это исправление по контракту «все предки».
### D6. Детерминированный порядок при совпадении ключа
Ключ INPUT `tree_path || line_id` совпадает с `path` дочернего узла, если `line_id` равен id дочерней статьи, а строка привязана к нелистовой статье. На AHR такое бывает: после 0033 у статей глубины 3 есть дети глубины 4, и строки синхронизации сателлитов привязаны к статьям глубины 3. Сейчас порядок при таком совпадении зависит от плана, а скелет и полный вывод могли бы упорядочить строки по-разному.
Добавляется tie-break `ORDER BY _sort, line_id NULLS FIRST`: при совпадении узел идёт раньше строки. Для OTCH9F добавляется `id` после `(expense_item_id, month)`. GET меняется только там, где порядок раньше был не определён.
### D7. JIT выключается в построителях и на пути правки (согласовано 28.10, решение 8)
`SET jit = off` ставится как атрибут функций `_form_affected_rows`, `get_form2_ahr_satellite_delta`, `v_form2_sheet_sections` и `v_form1_sheet_sections`. Причины две.
1. Запрос с фокусом в custom-плане может оценить стоимость выше `jit_above_cost` и потратить 200+ мс на компиляцию. Это больше всего бюджета в 50 мс.
2. План кэшируется один на оператор, общий для GET и правок в одном backend. Дешёвые custom-планы правок снижают среднюю стоимость custom-плана, и после этого GET может перестать переходить на generic-план. Тогда GET будет каждый раз платить JIT (+210–230 мс, М12).
По М12 JIT на этих запросах только вредит: 534 мс против 240 мс. Атрибут `SET` не мешает: plpgsql не инлайнится.
Почему это безопасно на проде:
- `jit` — пользовательский параметр (контекст `PGC_USERSET`). Поэтому `CREATE FUNCTION … SET jit = off` выполняется правами владельца схемы, без суперпользователя.
- Значение действует только во время выполнения функции, включая вложенные вызовы, и восстанавливается при выходе. Настройки сервера, роли и сессии не меняются, другие запросы приложения JIT по-прежнему используют по общей конфигурации.
- Связь с AURORA-1849 («Прод-Postgres: согласовать work_mem и jit»): та задача о серверной или ролевой конфигурации, она остаётся за администраторами. Наш атрибут от её исхода не зависит и с ним не конфликтует. Если на проде JIT уже выключен, атрибут ничего не меняет. `work_mem` в этой задаче не трогаем.
Отвергнутые варианты:
- (а) `jit = off` только на пути правки. Не закрывает причину 2. Это запасной ход, если GET регрессирует (см. Risks).
- (б) `plan_cache_mode = force_custom_plan`. Добавляет 7–8 мс планирования на каждую правку и усиливает причину 2. Тоже запасной ход, только на пути правки.
- (в) Настройка сервера Postgres. Не зона команды (решение 8, AURORA-1849).
### D8. Тела функций в миграции — буквальные, снятые с мигрированной БД
Тела `v_form2_view` и `get_form2_ahr_satellite_delta` берутся из `pg_get_functiondef` на БД, мигрированной до 0050, и правятся вручную. Регулярные выражения поверх `pg_get_functiondef`, как в 0037, не используются.
Отвергнутый вариант — DO-блок с `regexp_replace`. Правка затрагивает десятки мест, а регулярка в 0037 уже показала хрупкость (ветка `CONTINUE`).
Защита от отката 0037 — тесты из раздела «Тестирование», п. 7.
### D9. Порядок операторов в миграции 0051
Старые сигнатуры удаляются явно, **без CASCADE**. Если обнаружится скрытая зависимость, миграция упадёт, а не удалит лишнее. Порядок:
1. `DROP FUNCTION v3.v_form2_view(integer, character varying, text[])`, затем `v3.v_form2_sheet_jsonb(integer, character varying, text[])`, затем `v3.v_form2_sheet_sections(integer, character varying, text[])`.
2. Затем `v3.v_form1_sheet_jsonb(integer, character varying, character varying, text[])` и `v3.v_form1_sheet_sections(integer, character varying, character varying, text[])`.
3. `CREATE` в порядке «вызываемая раньше вызывающей», потому что тела LANGUAGE sql проверяются при создании:
- `*_sheet_sections`;
- `*_sheet_jsonb`;
- `v_form2_view`, `v_form1_view`;
- `v_form_view` (`CREATE OR REPLACE`);
- `_form_affected_rows` (`CREATE OR REPLACE`);
- `get_form2_ahr_satellite_delta` (`CREATE OR REPLACE`).
Без DROP функции с DEFAULT-параметрами дали бы неоднозначные перегрузки: вызов `v_form2_view(a, b, c)` из `v_form_view` подходит к обеим.
Ограничения формата:
- `api/alembic/sql_migration.py` пропускает строки, начинающиеся с `--`, и ищет `$tag$` построчно. Открывающий и закрывающий тег должны стоять на разных строках, закрывающий — в начале строки, как в существующих файлах.
- SQL совместим с PostgreSQL 15.12: без `any_value`, у подзапросов в FROM есть алиасы.
Файлы: `api/alembic/versions/0051_upd_form_cells_subtree.py` (`down_revision = "0050"`, `run_sql_file_migration`) и `api/alembic/versions/sql/0051_upd_form_cells_subtree.sql`. `downgrade()` пустой, как во всех миграциях.
## Risks / Trade-offs
- **Суммы предков в режиме фокуса разойдутся с полной сборкой.** Защита: параметризация вместо копии (D1); побайтная эквивалентность на всех строках тестовых листов («Тестирование», п. 1–2); та же проверка на данных стенда («Замер», шаг 4).
- **`sort_order` разойдётся с полным листом, и фронт молча испортит соседнюю строку.** Защита: ранг по скелету одним кодом (D2), tie-break (D6), тесты после POST и DELETE line и тест на совпадение ключа.
- **Пустой фокус превратится в полный лист** (NULL из `array_agg`). Последствия: дельта АХР отдаст весь лист, правка снова станет дорогой. Защита: семантика `'{}'` (D1), `COALESCE` в вызывающих, тест п. 6.
- **Откатится правка 0037** (формула лимитов, сопоставление статей глубины 4). Защита: D8 и тесты п. 7.
- **Регресс GET** из-за общего кэша планов, лишнего скелета или выключенного JIT. Защита: D7; требование «GET листа не меняется»; замер GET до и после со смешанной нагрузкой («Замер», шаг 5).
- **Generic-план для запроса с фокусом окажется плохим.** Фильтры фокуса стоят в базовых CTE (`tw`, `page`, `agg`/`aq_q`), которые материализуются, поэтому объём ниже по плану мал при любом плане.
- **Сумма ROOT растёт с размером листа**: агрегат предка суммирует все строки под ним. Числовой `SUM` без jsonb на 2092 строках, по оценке, укладывается в 10–20 мс, а вложенные циклы `tw ⋈ aq_q` (около 100 мс) сужаются до 4–5 узлов. Инкрементальные суммы — это уже другая задача.
- **Регресс производительности** (GET больше +10 %, правка больше 50 мс, очередь на rate 5) мерж не блокирует (решение 9). Исполнитель может итерировать **только на пути правки**, по шагам:
1. `SET plan_cache_mode = force_custom_plan` на `_form_affected_rows` и дельте;
2. снять `SET jit = off` с построителей, оставив его на `_form_affected_rows` и дельте;
3. если не помогло — стоп и вопрос контроллеру с EXPLAIN (ANALYZE, BUFFERS) запроса правки и GET.
Скелет `sort_order` (D2) без контроллера не откатывать. Оставшийся недобор фиксируется в PR и уходит в follow-up.
- **Нарушение корректности** (расхождение эквивалентности, снимка GET или защиты 0037) — стоп и эскалация, никаких обходов (решение 9).
- **Права, аудит, синхронизация сателлитов** не затронуты: это слой выше `_form_affected_rows`.
- **Зависимости.** AURORA-1844 — шов и контракт, это база ветки. AURORA-1846 нужна только для будущего фокуса FORM_4. Номер миграции может конфликтовать с 1831 и 1846.
## Migration Plan
- PR идёт в ветку `feat/215-aurora-1844-upd-form-cells-full-rows` (стек, Gitea #235). После слияния 1844 в `test` ветка перебазируется на `test`.
- **Перенумерация.** Если до слияния стека в `test` попадут 1831 (`0050_project_technical_number_lock`) или 1846 (`0050_form4_sheet_sum`), 1844 получит следующий свободный номер, а 1850 — номер после него. При rebase меняются имена файлов, `revision`, `down_revision` и ссылка на SQL-файл в `.py`. Содержимое миграции от номера не зависит: 1846 меняет только `v_form4_sheet_jsonb`, 1831 — `add_project`, пересечений по функциям нет.
- Откат: `downgrade()` пустой. Откат — это новая миграция, которая восстанавливает тела из 0050 и 0037 (снять их через `pg_get_functiondef` до применения).
- `ARCHITECTURE.md`: в абзац о `_form_affected_rows` добавить, что FORM_1 и FORM_2 собираются построителями с фокусом (`p_focus_lines`), `sort_order` берётся из скелета листа, FORM_4 — полная сборка до AURORA-1846.
## Тестирование
Тесты интеграционные: `api/tests/integration/`, новый файл рядом с `test_upd_form_cells_full_rows.py`. Существующие тесты 1844 остаются и должны проходить. Время в тестах не проверяется.
Тестовое окружение:
- Полный `pytest` гоняется на PG16: контейнер `perf-pg`, порт 55432, отдельная БД `dfip_1850`, создаётся командой `docker exec perf-pg createdb -U perf dfip_1850`. Запуск: `DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1850`; `--init_db` только на пустой БД. Пароли `OPENBAO__TEST_*` по умолчанию `admin123` (`api/tests/conftest.py`).
- **БД `perf` (стенд замеров) не трогать никогда.** Накатить туда 0051 раньше замера «до» нельзя.
- Тела функций для D8 снимаются через `pg_get_functiondef` с тестовой БД `dfip_1850` на ревизии 0050, до создания 0051.
- Совместимость с PostgreSQL 15.12 проверяет контроллер (контейнер `dfip-pg15`, порт 55434). В плане это отдельный шаг.
1. **Побайтная эквивалентность на уровне SQL** (новый хелпер в `sheet_contract.py`).
- Для набора `p_ids` сравниваются `SELECT row_type, depth, sort_order, data::text FROM v3._form_affected_rows(...)` и эталон.
- Эталон — фильтр 0050, применённый к полному `v3.v_form_view` с теми же параметрами: агрегаты по пути плюс INPUT по `line_id`; для сателлитов — по `id`, для AHR_LIMIT — по пути шаблона, для OTCH9F — по разделу. Эталон пишется в коде теста, в БД он не попадает.
- Сравнение `data::text` побайтное: jsonb-текст канонический, поэтому видна и разница в масштабе numeric (`30` и `30.00`).
- Проверка **на каждой строке INPUT** тестового листа по очереди, плюс пакеты из строк разных ветвей.
- Покрытие: FORM_1 AHR (Support и Development), CAP (Support), OPER; FORM_2 AHR, CAP, OPER, AHR_LIMIT, AHR_RENT, AHR_UTILITY, AHR_SECURITY, OTCH9F. Строки OTCH9F вставляются через `db_exec` в `fixed_asset_report` (HTTP-ручки нет), минимум два месяца одного оборудования и второе оборудование той же статьи в том же месяце — так проверяется tie-break.
- Тестовые листы содержат строки в нескольких ветвях дерева, чтобы были первая и последняя строки листа и предки без соседей.
2. **Эквивалентность через HTTP** (`assert_patch_rows_match_sheet`): PATCH `/cell` и `/cells` на тех же листах, включая очистку (`null`), правку нечисловой колонки и `sections`.
3. **`sort_order` после POST и DELETE line**: добавить несколько строк, удалить строку в середине, править строку ниже неё; ответ сравнить с GET.
4. **Совпадение ключа сортировки**: в откатываемой сессии вставить `budget_line` с явным `id`, равным id дочерней статьи той статьи, к которой привязана строка. Последовательность при этом не трогается. GET дважды даёт один и тот же порядок, узел идёт перед строкой, ответ на правку совпадает с GET.
5. **Дельта АХР**: после правки AHR_RENT (форма 4 фикстуры) результат `SELECT * FROM v3.get_form2_ahr_satellite_delta(4, 'AHR_RENT', id)` (через `db_exec` в той же сессии) совпадает со строками GET AHR того же `sort_order`. В нём есть синхронизированная строка INPUT и предки всех уровней, включая SUB_SUB_ITEM, если они есть на пути.
6. **Пустой фокус**: `get_form2_ahr_satellite_delta` для несуществующего `source_row_id` возвращает 0 строк. Вызов построителя с `'{}'` тоже даёт 0 строк.
7. **Защита правки 0037** — по одному тесту на каждую функцию:
- **`v_form2_view` (формула лимитов).** GET AHR_LIMIT для строки LEAF «- принтеры» (`limit_template.id = 13`, код `204620030`, засеяна в `0013_form2_limit.sql:1707`) после правки `qty_q1` даёт `amount_q1 = qty_q1 × 0.57 × 10900 / 4`. Тот же тест проверяет, что `pg_get_functiondef` новой `v_form2_view` содержит `_form2_ahr_limit_amount`.
- **`get_form2_ahr_satellite_delta` (сопоставление по `num_group_id`).** Правка строки AHR_LIMIT, код шаблона которой равен `num_group_id` статьи AHR глубины 4, даёт непустую дельту АХР с синхронизированной строкой этой статьи. Если такого шаблона в справочнике нет, тест делает `skip` с причиной. Тот же тест проверяет, что `pg_get_functiondef` новой дельты содержит `ei.num_group_id = lt.expense_item_code` вне условия `ei.depth = 3 AND (`.
8. **Атомарность пакета** — существующий `test_bad_column_in_batch_fails_and_applies_nothing` остаётся.
## Замер до/после
Замер выполняет контроллер после основных ночных замеров. В плане это точка ожидания: PR открывается без цифр, цифры дописываются в PR позже. Пороги — не условие мержа (решение 9). Шаги 3 и 4 проверяют корректность: расхождение в них — стоп и эскалация. Код стенда `perf/` не меняется. Сценарий — шаг `coauthor` (`perf/e2e/scenarios/scroll-coauthor.spec.ts` и бот `perf/perfkit/coauthor.py`), цель `FORM_2/AHR/*`, rate 1 и 5, масштаб 1, CPU 1x. Метрики берутся из `sql/coauthor/sql_top.json`, `summary.md` и строк `db_time` в `perf/.run/api.log`.
1. **«До», 1154 строки.** Код ветки 1844, `stand.sh reset`, `perfkit.gen.run --scale 1`, `perfkit.gen.verify --scale 1` без grow, затем coauthor (около 7 мин). Ожидаемое значение — около 310 мс на `upd_form_cell` (одна сборка).
2. **Снимок GET «до».** Для всех листов FORM_1 и FORM_2 из манифеста сохранить JSON ответа `GET /form/{id}/sheet/{sheet}` (с `direction`, где он есть).
3. **Переход на ветку 1850.** `stand.sh migrate`, перезапуск api, снова снимок GET. Ответы должны попарно совпасть (требование «GET листа не меняется»). Различие допустимо только в порядке строк с совпадающим ключом, и такие строки нужно перечислить.
4. **Эквивалентность на данных стенда.** psql, только чтение. Для 50 случайных строк FORM_2/AHR и 20 строк каждого из листов CAP, OPER, AHR_RENT и FORM_1/AHR сравнить `v3._form_affected_rows(form, sheet, ARRAY[id], dir, NULL)` с эталонным фильтром по `v3.v_form_view` (тот же запрос, что в тесте п. 1). Расхождений должно быть 0.
5. **GET без регресса.** В одном psql-соединении сделать 5 прогревочных вызовов, затем 20 раз чередовать `SELECT count(*) FROM v3.v_form_view(<FORM_2 rf>, 'AHR', NULL, NULL)` и `SELECT count(*) FROM v3.upd_form_cell(...)` в транзакции с ROLLBACK, измерить по `\timing` или `pg_stat_statements`. Средний GET не больше значения на коде 1844 + 10 % (то же измерение на шаге 1).
6. **«После», 1154 строки.** coauthor, rate 1 и 5. Критерии:
- `mean_exec_time` вызова `upd_form_cell` ≤ 50 мс;
- вызовов `v3.v_form_view` с `toplevel = false` — 0;
- `calls` у `v3.v_form2_view(p_form_id, p_sheet, p_sections)` равно числу GET листа, а не числу правок;
- на rate 5 `(coauthor_echoed + coauthor_errors) / coauthor_sent ≥ 0.95` — очереди нет.
7. **«После», 2092 строки.** `perfkit.gen.grow --scale 1` (около 4 мин), затем coauthor. Критерий: `mean_exec_time` ≤ 50 мс и тот же критерий очереди.
Отчёт: таблица «до» и «после» по шагам 1, 6, 7 с путями к `sql_top.json` и `summary.md`, результаты шагов 3–5. Общее время — около 45 мин. Точка «до» на 2092 строках не снимается (см. решения без обсуждения).
## Оставлено исполнителю
- Конкретная перестройка CTE внутри построителей: разделение `tw`/`tw_focus`, `page`/`page_focus`, размещение скелета. Ранг через `ROW_NUMBER` по скелету или эквивалентный способ — главное, одним выражением для обоих режимов.
- Вычисление узлов фокуса и `∪ desc_ids` в `DECLARE`/`SELECT INTO` перед `RETURN QUERY`, если это даёт лучший план.
- Сужать ли агрегат шаблона AHR_LIMIT.
- Имена тестовых хелперов и раскладка тестов по файлам.
- Текст абзаца в `ARCHITECTURE.md`.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-28 | Capability `sheet-cell-edit`. В спецификацию как ADDED вошёл и контракт ответа 1844 (полные строки, явные `null`, `sections`, атомарность пакета) | `openspec/specs/` пуст; без контракта требование о `sort_order` и эквивалентности не к чему привязать | Переименование capability при архивации; пересечение с change 1845 или 1848, если там заведут ту же область |
| 2026-10-28 | Параметр `p_focus_lines integer[] DEFAULT NULL` последним во всех построителях; `NULL` — полный лист, `'{}'` — пустой результат | Совместимость существующих вызовов по позиции; явное различие «весь лист» и «ничего» | Если вызывающая сторона передаст NULL вместо `'{}'`, будет полная сборка, правильная по содержимому, но медленная; ловит тест п. 6 |
| 2026-10-28 | `sort_order` считает построитель по скелету листа в обоих режимах (новая колонка `_sort_rank`); view перестают считать собственный `ROW_NUMBER` | Один источник порядка для GET и правки, требование точного `sort_order` | +1–3 мс к GET на скелет; при ошибке в скелете меняется и GET, что ловит снимок GET на стенде |
| 2026-10-28 | Tie-break `ORDER BY _sort, line_id NULLS FIRST` (узел раньше строки); для OTCH9F — `id` после `(expense_item_id, month)` | Порядок при совпадении ключа сейчас не определён; без tie-break скелет и вывод могут разойтись | Порядок строк в GET меняется только в таких случаях, возможно непривычно для пользователя |
| 2026-10-28 | Новая `v3.v_form1_view`: в неё вынесена ветка FORM_1 AHR/CAP/OPER из `v_form_view` | Одна проекция строки FORM_1 для GET и правки | Ещё одна функция в схеме; при ошибке переноса меняется GET FORM_1, что ловят снимок GET и тесты 1844 |
| 2026-10-28 | Узлы фокуса берутся из `budget_line` без фильтра `direction`, а INPUT — с фильтром | Так ведёт себя 0050: предки по всем строкам правки, INPUT только видимые в листе | Если правильнее было не возвращать предков для строки чужого направления — лишние, но верные строки в ответе |
| 2026-10-28 | Сателлиты: фильтр по `id` и `ROW_NUMBER` по лёгкому скелету; AHR_LIMIT: фильтр вывода по пути шаблона, агрегат шаблона может остаться полным; OTCH9F: `base` по разделам изменённых строк | Размер шаблона AHR_LIMIT не зависит от числа строк формы; окна OTCH9F замкнуты в разделе | AHR_LIMIT остаётся O(размер шаблона) на правку, сейчас это доли мс |
| 2026-10-28 | Дельта АХР теперь включает предков уровня SUB_SUB_ITEM | Контракт «все предки»; после 0033 у AHR есть статьи глубины 4, их агрегат у соавторов устаревал | Если фронт АХР не ожидает таких строк, получит лишние строки; `updateCells` сливает по ключу, так что это безопасно |
| 2026-10-28 | `_form_affected_rows` помечается `STABLE` | Функция только читает, вызывается из STABLE-функции дельты | STABLE-функция видит снимок вызывающего оператора. Сейчас её зовёт отдельный оператор `RETURN QUERY` после `_apply_form_cell`, и правки видны. Если когда-нибудь её вызовут в одном операторе с правкой, ответ будет со старыми значениями; это ловят тесты эквивалентности |
| 2026-10-28 | Тела `v_form2_view` и `get_form2_ahr_satellite_delta` в 0051 — буквальные, снятые через `pg_get_functiondef` с БД после 0050, а не регулярка поверх функции | Правка затрагивает десятки мест; регулярка хрупкая | Если тело снято с БД не той версии, откатится 0037; ловят тесты п. 7 |
| 2026-10-28 | DROP старых сигнатур без CASCADE в одной миграции, затем CREATE в порядке «вызываемая раньше» | Неоднозначные перегрузки с DEFAULT; скрытая зависимость должна ронять миграцию, а не удалять объекты | Падение миграции при неизвестной зависимости — разбор вручную |
| 2026-10-28 | Имя миграции `0051_upd_form_cells_subtree` | Номер из решения человека, имя по change id | Переименование при rebase |
| 2026-10-28 | Эталон эквивалентности — фильтр 0050 поверх полного `v_form_view` в коде теста, сравнение `data::text` побайтно, проход по каждой строке INPUT тестового листа | Требование «побайтно»; dict-сравнение в HTTP не видит масштаб numeric; полный проход закрывает первую и последнюю строку без ручного выбора | Тесты дольше на несколько секунд |
| 2026-10-28 | Порог «GET не медленнее более чем на 10 %» и замер GET в одном соединении с чередованием правок | Нужен численный критерий регресса; чередование проверяет общий кэш планов | Порог 10 % может быть слишком мягким или жёстким при шуме стенда |
| 2026-10-28 | Отсутствие очереди на rate 5 проверяется как `(echoed + errors) / sent ≥ 0.95`, без задержки эха по времени | Бот не пишет задержку эха, а `perf/` сейчас не трогаем; при отсутствии очереди сервер отвечает почти на все отправленные правки | Растущую задержку при почти полном ответе этот критерий не увидит; можно позже добавить в бот p50 и p95 задержки |
| 2026-10-28 | Точка «до» на 2092 строках не снимается; ориентир — 1,46 с из шага memory (две сборки) и около 0,73 с после 1844 по оценке | Критерий абсолютный (≤ 50 мс); экономия около 30 мин на `reset`, `gen` и `grow` с кодом 1844 | В отчёте нет измеренной точки «до» на большом листе, только оценка |
| 2026-10-28 | Снимок GET всех листов FORM_1 и FORM_2 до и после миграции на стенде и SQL-эквивалентность на 50+ строках стенда | Тестовые данные малы; реальный объём и разброс статей есть только на стенде | +10 мин к замеру |
| 2026-10-28 | Контроллер: при регрессе производительности (GET > +10 %, правка > 50 мс, очередь на rate 5) исполнитель итерирует только на пути правки: `plan_cache_mode = force_custom_plan` на `_form_affected_rows`/дельте; снятие `SET jit = off` с построителей при сохранении его на пути правки; если не помогает — стоп и вопрос контроллеру с EXPLAIN. Скелет `sort_order` (D2) без контроллера не откатывается | Пороги не блокируют мерж (решение 9), но итерации не должны задевать GET и корректность; D2 — основа точного `sort_order` | Лишние итерации исполнителя; недобор цифр уходит в follow-up вместо доработки в этом PR |
| 2026-10-28 | Контроллер: полный pytest на PG16 в контейнере `perf-pg` (порт 55432), отдельная БД `dfip_1850`; БД `perf` стенда не трогается никогда; тела функций для D8 снимаются через `pg_get_functiondef` с `dfip_1850` на ревизии 0050; совместимость с PG 15.12 проверяет контроллер (`dfip-pg15`, порт 55434) отдельным шагом плана | Изоляция от стенда замеров: 0051 в БД `perf` до замера «до» испортит базовую точку | При ошибке в БД — пересоздать `dfip_1850`; если 0051 попадёт в `perf`, придётся заново делать reset и gen стенда (около 15 мин) |
| 2026-10-28 | Контроллер: замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания, PR открывается без цифр | Стенд занят ночным прогоном; пороги не условие мержа | Цифры в PR появляются позже мержа, если ревью пройдёт раньше |
| 2026-10-28 | Контроллер: тест защиты 0037 разнесён по функциям — формула лимитов через `v_form2_view`, сопоставление `num_group_id` через дельту | Каждая из двух правок 0037 живёт в своей функции; падение теста сразу указывает, где откат | Нет: только раскладка тестов |
| 2026-10-28 | План: шаблон `dfip_1850_base` (ревизия 0050 + фикстура) для быстрого сброса `dfip_1850` и вспомогательная `dfip_1850_snap` для снимка GET; обе в `perf-pg`, БД `perf` не затрагивается | Миграция с DROP без `IF EXISTS` не перезапускается на той же БД; сброс из шаблона быстрее `--init_db` | Две лишние БД в контейнере; при ошибке — пересоздать из `dfip_1850` |
| 2026-10-28 | План: тела 8 функций с ревизии 0050 коммитятся в `openspec/changes/aurora-1850-upd-form-cells-subtree/before-0051.sql` (не миграция) | D8 требует буквальных тел; файл — база для ревью (diff «до» и 0051) и для будущей миграции отката; архивируется вместе с change | Лишний файл ~5 тыс. строк в change |
| 2026-10-28 | План: локальный снимок GET всех листов FORM_1/FORM_2 до и после 0051 на детерминированно засеянной `dfip_1850_snap` после каждой задачи с построителями | Требование «GET листа не меняется» проверяется на стенде только после PR; локальная проверка ловит регресс GET раньше | Данные посева меньше стенда; снимок стенда (шаг 3 замера) остаётся обязательным |
| 2026-10-28 | План: «0 вложенных полных сборок» в тестах проверяется через `SET LOCAL track_functions = 'all'` и `pg_stat_get_xact_function_calls(v_form_view)`; без прав суперпользователя тест делает skip | Единственный способ увидеть вложенный вызов plpgsql-функции из теста | На роли без суперпользователя критерий проверяется только замером стенда (шаг 6) |
| 2026-10-28 | План: тест эквивалентности дополнительно сравнивает с эталоном вывод построителя в режиме фокуса (`v_form2_view`/`v_form1_view` с `p_focus_lines`), а не только `_form_affected_rows` | Даёт красный тест до реализации (путь `_form_affected_rows` на 0050 эквивалентен эталону по построению) и локализует ошибку в построителе | Тесты дольше на несколько секунд |
| 2026-10-28 | План: скелет `sort_order` сателлитов сохраняет те же `JOIN v3.budget_line` и `JOIN v3.vsp`, что и GET; соединение с `vsp` убирается только при доказанных `NOT NULL` и FK | D4 говорит «без соединения с vsp», но в GET соединение внутреннее и может отбрасывать строки; тогда ранг сдвинется | Соединение по первичному ключу почти бесплатно; если убрать его без доказательства — тихо неверный `sort_order` |
| 2026-10-28 | План: `v_form1_view` принимает только AHR/CAP/OPER, на других листах — `RAISE EXCEPTION` | SMETA FORM_1 собирается `v_form_view` до вызова; явная ошибка лучше пустого результата | Нет: функция внутренняя |
| 2026-10-28 | План: раскладка тестов — `test_upd_form_cells_subtree.py` (эквивалентность, `sort_order`, дельта, JIT) и `test_upd_form_cells_subtree_0037_guard.py`; SQL-хелперы в `sheet_contract.py`; HTTP-хелперы и фикстуры импортируются из `test_upd_form_cells_full_rows.py` | Оставлено исполнителю (design.md); решено в плане, чтобы задачи 2, 8, 9 ссылались на одни имена | Нет: только раскладка |
| 2026-10-28 | План: защита 0037 для дельты проверяет `pg_get_functiondef` регуляркой `ei\.depth\s*=\s*3\s+AND\s*\(` (вид до 0037), а не литералом | В теле до 0037 между `3` и `AND (` стоят переводы строки и отступы; литерал не поймал бы откат | Нет |
| 2026-10-28 | Контроллер (R-1, R-10): скелет сателлитов сохраняет те же JOIN (`budget_line`, `vsp`), что GET (0033); JOIN `vsp` убирать только при доказанных NOT NULL и FK. Одобрено главным контроллером | Рулинг уточняет D4: внутренний JOIN может отбрасывать строки и сдвигать ранг | Тихо неверный `sort_order` сателлитов; ловят тесты эквивалентности и снимок GET |
| 2026-10-28 | Контроллер (R-2): роль `perf` в `perf-pg` и `dfip-pg15` — суперпользователь, `SET LOCAL track_functions` доступен; тест «0 вложенных `v_form_view`» не пропускается. Skip этого теста считается провалом | Иначе критерий «нет полной сборки на пути правки» остаётся непроверенным до замера | Если на другой роли тест скипнется, критерий проверяется только замером стенда |
| 2026-10-28 | Контроллер (R-3): дополнительные БД `dfip_1850_base` и `dfip_1850_snap` в `perf-pg` одобрены; допустим только префикс `dfip_1850*`, БД `perf` не трогается | Быстрый сброс и снимок GET (см. решения плана) | Две лишние БД в контейнере |
| 2026-10-28 | Контроллер (R-4): `before-0051.sql` коммитится в каталог change | База ревью и отката (D8) | Лишний файл в change |
| 2026-10-28 | Контроллер (R-14): подпись коммитов `Co-Authored-By: Claude Opus 5.5` по плану и правилам волны 2, хотя системное напоминание сессии называет Sonnet 5.5 | Явная инструкция пользователя приоритетнее напоминания | Неверное имя модели в трейлере коммитов |
| 2026-10-28 | Исполнитель Task 1 (T1.2): посев снимка GET создаёт строки FORM_1 AHR/CAP и для Support, и для Development; снимок включает и фикстурные формы 1 и 4 | `expense_item.direction` везде Support, иначе ветка Development в снимке пуста | Снимок содержит чуть больше строк, чем описано в плане; на проверку корректности не влияет |
| 2026-10-28 | Исполнитель Task 3 (T3.1): ранг скелета присоединяется внешним `JOIN sk` поверх `UNION` построителя, а не внутри веток; ключ скелета один bigint (узел = `-id`, строка = `lid`) | Вариант с `JOIN sk` в каждой ветке обрушил оценку строк, планировщик выбрал nested loop, GET AHR вырос со 105 до 203 мс; внешний JOIN даёт около 114 мс (+8 %) | Хрупкость плана на других объёмах; ловит замер GET на стенде |
| 2026-10-28 | Исполнитель Task 4 (T4.4): JOIN `vsp` в скелете сателлитов оставлен, хотя `vsp_id` и `line_id` NOT NULL с FK (доказано по схеме); OTCH9F: перестановка строк с равным `(expense_item_id, month)` по tie-break `id` в снимке GET — 4 строки формы 26 (id 4/1 месяц 1, id 6/2 месяц 2), содержимое строк одинаково | Рулинг R-1; порядок при равных ключах раньше не был определён (D6) | Если фронт опирался на прежний случайный порядок равных ключей, порядок двух пар строк изменится |
| 2026-10-29 | Контроллер (M-3): тест «0 вложенных `v_form_view`» жёстко требует `rolsuper` (assert вместо skip) | Рулинг R-2 | На не-суперпользовательской роли тест падает; нужен суперпользователь или проверка `has_parameter_privilege` |

View File

@ -0,0 +1,47 @@
# Proposal
## Why
Правка ячейки на сервере стоит столько же, сколько сборка всего листа. На стенде профилирования (`perf/REPORT.md`, М3; `perf/report-data/20261026-s1/sql/coauthor/sql_top.json`) правка на FORM_2/AHR в 1154 строки занимает 630 мс: `v3.upd_form_cells` дважды собирает лист через `v3.v_form_view`, по 310 мс на сборку. На листе в 2092 строки (после grow) правка занимает 1,46 с. WebSocket-соединение обрабатывает сообщения последовательно, поэтому потолок — около 1,6 правки/с (на 2092 строках около 0,7). Пользователь, который быстро вводит значения, видит, что подтверждения его правок отстают всё сильнее.
AURORA-1844 (PR #231, база этой ветки) убрала снимок «до» и `_jsonb_diff`. Теперь на правку приходится одна полная сборка. Её вызов вынесен в шов `v3._form_affected_rows`. Но и одна сборка — это около 310 мс, и время по-прежнему растёт с размером листа. Задача AURORA-1850 (Gitea #235, эпик AURORA-1834) убирает и её: правка собирает только затронутое поддерево, то есть изменённые строки и агрегаты их предков.
Критерий из Jira: правка ячейки на сервере занимает ≤ 50 мс независимо от размера листа, а суммы совпадают с полной сборкой (подтверждается тестом).
## What Changes
- `v3._form_affected_rows` для FORM_1 и FORM_2 больше не вызывает `v3.v_form_view` и не собирает лист целиком. Касается листов AHR/CAP/OPER, сателлитов AHR_RENT/AHR_UTILITY/AHR_SECURITY, а также AHR_LIMIT и OTCH9F.
- В существующие построители листа (`v3.v_form1_sheet_sections`, `v3.v_form2_sheet_sections` и их вызывающие) добавляется параметр «фокус по строкам». Значение NULL означает прежнее поведение, то есть полный лист. Параллельной копии сборки нет: GET листа и ответ на правку считаются одним кодом.
- `sort_order` в ответе на правку остаётся глобальным: он совпадает с номером строки в полном GET того же листа с теми же параметрами.
- `v3.get_form2_ahr_satellite_delta` переводится на тот же механизм. Это фоновое обновление листа АХР после правки или добавления строки сателлита, и сейчас оно строит весь AHR. Правка миграции 0037 (сопоставление строк AHR_LIMIT со статьями глубины 4) сохраняется.
- FORM_4 остаётся на полной сборке, пока не слита AURORA-1846 (PR #232). Критерий производительности для FORM_4 переносится.
- Новая миграция `0051_upd_form_cells_subtree`, номер — в стеке после 0050 из AURORA-1844. Номер может измениться при rebase (см. design.md).
- Новые интеграционные тесты эквивалентности сравнивают каждую строку ответа на правку с полным GET.
**Не меняется:**
- контракт HTTP (`PATCH /form/{id}/sheet/{sheet}/cell(s)`) и WebSocket (`cell_updated`): форма строк, состав строк, правила `sections`;
- содержимое GET листа. Исключение — детерминированный порядок строк при совпадении ключа сортировки (design.md, D6);
- проверки прав (`v3.upd_form_cells` с `p_user_id`, `can_edit`, `computed_field`), синхронизация сателлитов, `v3._apply_form_cell`;
- `add_budget_line` и `del_budget_line`: они по-прежнему отдают строки через полный `v_form_view`. Это задел на потом, не в этой задаче;
- фронт.
## Capabilities
### New Capabilities
- `sheet-cell-edit`: ответ на правку ячеек листа формы (HTTP и WebSocket), то есть состав строк, их содержимое и `sort_order` относительно полного GET; фоновое обновление АХР после правки сателлита; время правки на сервере. Каталог `openspec/specs/` пока пуст, поэтому в спецификацию вместе с новыми требованиями попадает и контракт, установленный AURORA-1844.
### Modified Capabilities
Нет.
## Impact
- SQL (`api/alembic/versions/`): новая миграция `0051_*.py` и `sql/0051_*.sql`. Пересоздаются `v3.v_form2_sheet_sections`, `v3.v_form2_sheet_jsonb`, `v3.v_form2_view`, `v3.v_form1_sheet_sections` и `v3.v_form1_sheet_jsonb`: у них меняется сигнатура, поэтому нужен DROP и CREATE. Появляется новая `v3.v_form1_view`. Переписываются `v3.v_form_view` (ветка FORM_1), `v3._form_affected_rows` и `v3.get_form2_ahr_satellite_delta`. Тело `v_form2_view` и `get_form2_ahr_satellite_delta` берётся из БД после миграции 0037.
- Python (`api/src`) не меняется: сигнатуры `upd_form_cell(s)` и `get_form2_ahr_satellite_delta` прежние.
- Тесты: `api/tests/integration/` — эквивалентность на базе `sheet_contract.py`.
- `ARCHITECTURE.md`: абзац о `_form_affected_rows` дополняется описанием фокусной сборки.
- Связанные задачи:
- AURORA-1844 — шов и контракт полных строк, обязательная предпосылка.
- AURORA-1846 — FORM_4. После её слияния — отдельная задача на фокус FORM_4 по разделу, программе и проекту.
- AURORA-1845 — сужена до кэша пользователя и формы на соединение. Пакетная обработка — отдельная неприоритетная задача. После 1850 ручной ввод не должен создавать очередь, это проверяется замером.
- AURORA-1838 и AURORA-1848 — фронт ищет строку по `(row_type, depth, sort_order)`, поэтому `sort_order` обязан быть точным. Явные `null` в строках ответа нужны для очистки ячеек.
- AURORA-1831 и AURORA-1846 занимают номер миграции 0050 в параллельных PR.

View File

@ -0,0 +1,114 @@
# Spec Delta
## Purpose
Ответ сервера на правку ячеек листа бюджетной формы (HTTP `PATCH /form/{id}/sheet/{sheet}/cell(s)` и WebSocket `cell_updated`): какие строки в нём приходят, какими они должны быть относительно полного GET листа и сколько времени правка может стоить на сервере.
## ADDED Requirements
### Requirement: Ответ на правку содержит затронутые строки целиком
Ответ на правку ячеек листов AHR/CAP/OPER форм FORM_1, FORM_2 и FORM_4 SHALL содержать каждую изменённую строку INPUT и все её строки-агрегаты, то есть всех предков по дереву статей до ROOT включительно. Для FORM_4 предками считаются только ROOT раздела, GROUP программы и ITEM проекта изменённой строки. Агрегат SHALL входить в ответ, даже если его значения не изменились. Ответ SHALL NOT содержать строку META и строки других ветвей дерева. Поле `data` каждой строки SHALL быть полной строкой в том же виде, что и в GET листа с теми же `direction` и `sections`: пустые значения передаются явным `null`. Если задан `sections`, ключи верхнего уровня у исключённых разделов SHALL отсутствовать в `data`.
#### Scenario: Правка числовой ячейки возвращает строку и её предков
- **WHEN** исполнитель меняет `plan.q1` у строки листа FORM_2/AHR
- **THEN** ответ содержит эту строку INPUT и по одной строке-агрегату на каждый уровень её пути в дереве статей
- **AND** `data` каждой строки ответа равно `data` строки того же `sort_order` из последующего GET листа
#### Scenario: Правка нечисловой ячейки всё равно возвращает предков
- **WHEN** исполнитель меняет `header.justification` у строки FORM_2/AHR
- **THEN** ответ содержит строку INPUT и всех её предков, хотя их суммы не изменились
#### Scenario: Очистка значения
- **WHEN** исполнитель записывает `null` в `plan.q1`
- **THEN** в строке INPUT ответа ключ `plan.q1` присутствует со значением `null` (или 0 — так же, как в GET)
- **AND** суммы предков в ответе уменьшены на прежнее значение ячейки
#### Scenario: Правка с фильтром разделов
- **WHEN** правка выполняется с `sections=plan`
- **THEN** ни одна строка ответа не содержит ключей разделов, отличных от `plan`, а также служебных ключей, которых нет в GET с тем же `sections`
### Requirement: sort_order в ответе совпадает с полным листом
Значения `row_type`, `depth` и `sort_order` каждой строки ответа на правку SHALL совпадать со значениями этой же строки в полном GET листа с теми же параметрами, выполненном сразу после правки. `sort_order` — сквозной номер строки во всём листе, а не номер внутри ответа. Это SHALL выполняться для первой и последней строки листа, после добавления и удаления строк в листе, а также при совпадении ключа сортировки у строки INPUT и узла дерева. Порядок строк в GET при совпадении ключа SHALL быть детерминированным и одинаковым в GET и в ответе на правку.
#### Scenario: Первая и последняя строка листа
- **WHEN** правится первая по порядку строка INPUT листа, а затем последняя
- **THEN** у каждой строки обоих ответов есть строка с тем же `(row_type, depth, sort_order)` в GET листа, и её `data` совпадает
#### Scenario: После добавления и удаления строк
- **WHEN** в лист добавлены строки (POST line), одна из строк в середине листа удалена (DELETE line), после чего правится строка, расположенная ниже удалённой
- **THEN** `sort_order` каждой строки ответа равен её `sort_order` в GET листа после этих операций
#### Scenario: Пакет правок по разным ветвям дерева
- **WHEN** через `PATCH /cells` одним запросом правятся строки двух разных ветвей дерева статей
- **THEN** ответ содержит обе строки INPUT и объединение их предков без дублей, и каждая строка совпадает со строкой GET того же `sort_order`
#### Scenario: Направление FORM_1
- **WHEN** правится строка FORM_1/AHR с `direction=Support`
- **THEN** `sort_order` строк ответа равен `sort_order` в GET FORM_1/AHR с `direction=Support`
### Requirement: Состав ответа на листах сателлитов, AHR_LIMIT и OTCH9F
На листах FORM_2 AHR_RENT, AHR_UTILITY и AHR_SECURITY ответ на правку SHALL содержать только изменённые строки. На AHR_LIMIT ответ SHALL содержать изменённые строки LEAF шаблона и их предков по шаблону (GROUP и SECTION). На OTCH9F ответ SHALL содержать все строки (все месяцы) того же сочетания статьи и оборудования, что и изменённая строка: остатки на начало месяца зависят от предыдущих месяцев. Для всех этих листов SHALL выполняться требование о совпадении `data` и `sort_order` с полным GET.
#### Scenario: Правка строки аренды
- **WHEN** исполнитель меняет `plan.q1` у строки AHR_RENT
- **THEN** ответ содержит ровно одну строку с `data.id` этой строки, и она совпадает со строкой GET AHR_RENT того же `sort_order`
#### Scenario: Правка количества в лимитах
- **WHEN** исполнитель меняет `qty_q1` у строки LEAF листа AHR_LIMIT
- **THEN** ответ содержит эту строку и строки её группы и раздела с пересчитанными `amount_q1`, и они совпадают с GET AHR_LIMIT
#### Scenario: Правка месяца в OTCH9F
- **WHEN** исполнитель меняет поступление за месяц у строки OTCH9F
- **THEN** ответ содержит строки всех месяцев того же оборудования той же статьи с пересчитанными остатками, и они совпадают с GET OTCH9F
### Requirement: Обновление АХР после правки сателлита
После правки или добавления строки на AHR_RENT, AHR_UTILITY, AHR_SECURITY или AHR_LIMIT формы FORM_2 сервер SHALL отправить подключённым к листу AHR событие (`cell_updated` или `row_added`). Событие SHALL содержать синхронизированные строки AHR и всех их предков, включая уровень SUB_SUB_ITEM. Каждая из этих строк SHALL совпадать со строкой того же `sort_order` полного GET листа AHR. Если синхронизированных строк нет, событие SHALL NOT содержать строк AHR, то есть ответ пуст, а не весь лист. Строки AHR_LIMIT SHALL сопоставляться и со статьями AHR глубины 4 по `num_group_id` (поведение миграции 0037).
#### Scenario: Правка аренды обновляет АХР
- **WHEN** исполнитель меняет `plan.q1` у строки AHR_RENT, связанной со строкой AHR
- **THEN** строки, которые сервер готовит для события на лист AHR, совпадают со строками GET AHR того же `sort_order`, и среди них есть синхронизированная строка INPUT с новой суммой
#### Scenario: Источник без синхронизированных строк
- **WHEN** запрашивается обновление АХР для строки сателлита, у которой нет связанной строки AHR
- **THEN** набор строк для события пуст
#### Scenario: Лимит на статье глубины 4
- **WHEN** правится строка AHR_LIMIT, код статьи которой совпадает с `num_group_id` статьи AHR глубины 4
- **THEN** набор строк для события содержит синхронизированную строку AHR этой статьи
### Requirement: Пакет правок атомарен
Если хотя бы одна правка в пакете отклонена (неизвестная колонка, чужая строка, нет прав, окно закрыто), SHALL NOT применяться ни одна правка пакета, и ответ SHALL быть ошибкой.
#### Scenario: Ошибочная колонка в пакете
- **WHEN** `PATCH /cells` содержит одну корректную правку и одну с несуществующей колонкой
- **THEN** ответ — ошибка 4xx, и GET листа после запроса совпадает с GET до него
### Requirement: GET листа не меняется
Содержимое GET листов FORM_1 и FORM_2 (строки, `data`, `sort_order`) после изменения SHALL совпадать с содержимым до него на тех же данных. Исключение — порядок строк с совпадающим ключом сортировки: до изменения он не был определён. Время GET FORM_2/AHR SHALL NOT вырасти больше чем на 10 %.
#### Scenario: Снимок GET до и после миграции
- **WHEN** на стенде профилирования снят GET всех листов FORM_1 и FORM_2 из манифеста до применения миграции и после неё
- **THEN** ответы попарно совпадают
#### Scenario: Время GET после смешанной нагрузки
- **WHEN** в одном соединении с БД чередуются правки ячеек и GET листа FORM_2/AHR
- **THEN** среднее время GET не больше базового + 10 %
### Requirement: Время правки ячейки на FORM_1 и FORM_2
Правка одной ячейки листа FORM_1 или FORM_2 SHALL NOT собирать лист целиком: на правку SHALL приходиться 0 вложенных полных сборок листа. Среднее серверное время `v3.upd_form_cell` на FORM_2/AHR SHALL быть не больше 50 мс как на листе в 1154 строки, так и на листе в 2092 строки. Требование не распространяется на FORM_4 до слияния AURORA-1846.
#### Scenario: Соавтор на листе масштаба 1
- **WHEN** на стенде профилирования (масштаб 1, без grow, FORM_2/AHR 1154 строки) выполняется шаг `coauthor`
- **THEN** в `sql/coauthor/sql_top.json` `mean_exec_time` вызова `v3.upd_form_cell` ≤ 50 мс
- **AND** вызовов `v3.v_form_view` не верхнего уровня (`toplevel = false`) нет, а число вызовов `v3.v_form2_view(p_form_id, p_sheet, p_sections)` равно числу GET листа и не зависит от числа правок
#### Scenario: Соавтор на выросшем листе
- **WHEN** после `perfkit.gen.grow` (FORM_2/AHR 2092 строки) шаг `coauthor` выполняется повторно
- **THEN** `mean_exec_time` вызова `v3.upd_form_cell` ≤ 50 мс
### Requirement: Ручной ввод без очереди
Сервер SHALL успевать подтверждать правки одного соединения с частотой ручного ввода (5 правок/с), не накапливая очередь.
#### Scenario: Соавтор с частотой 5 правок в секунду
- **WHEN** бот соавтора на FORM_2/AHR отправляет 5 правок/с в течение шага `coauthor`
- **THEN** число полученных ответов (эхо и ошибки) составляет не меньше 95 % от числа отправленных правок