План в 10 задач по design.md: тестовая БД dfip_1850 на 0050 и тела функций до 0051 (D8), красный тест эквивалентности, построители с фокусом и _sort_rank (FORM_2, затем FORM_1 и v_form1_view), _form_affected_rows, дельта АХР, jit = off, защита 0037, документация. Решения плана дописаны в design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 KiB
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-1850A=$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) мерж не блокирует. Итерировать можно только на пути правки и строго по шагам:
SET plan_cache_mode = force_custom_planна_form_affected_rowsи дельте;- снять
SET jit = offс построителей, оставив его на_form_affected_rowsи дельте; - если не помогло — стоп и вопрос контроллеру с
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
- Пустой фокус превращается в полный лист.
array_aggпо пустому набору даёт NULL, и вызов сp_focus_lines = NULLвернёт весь лист. Ожидание:'{}'даёт 0 строк у каждого построителя, у_form_affected_rowsи у дельты. Тестtest_empty_focus_returns_nothing(Task 2). - Строка FORM_1 чужого направления в пакете. Правка строки Development при
direction=Supportвозвращает предков (eid без фильтра направления), но не саму строку. Тест: случай FORM_1/AHR/Support вtest_affected_rows_match_referenceвключает в пакет строку Development (Task 2). - OTCH9F с
equipment_name IS NULL. Разделы окна по NULL-оборудованию должны совпасть с полным листом: в фильтреbaseнуженIS NOT DISTINCT FROM, а не=. Тестовые данные OTCH9F содержат оборудование NULL (Task 2), реализация в Task 4. - Скелет сателлитов теряет или добавляет строки относительно GET. В GET есть
JOIN v3.budget_lineиJOIN v3.vsp. Если скелет нумерует поrent_detailбез того же соединения,sort_orderсдвинется. Ожидание: в скелете тот же набор строк, что в GET. Тест: эквивалентность сателлитов по каждой строке (Task 2), реализация в Task 4. - Фокус при
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:
#!/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:
#!/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:
$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.
Затем создать шаблон:
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: Снять тела функций
$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:
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: Найти вызывающих построители
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: две статьи, которые попадают в ветку OTCH9Fv_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:
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:
#!/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: Коммит
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).
_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).
- для FORM_2 —
-
Step 2: Тесты в
test_upd_form_cells_subtree.py
Годы новых форм — 2041–2060, у каждого теста свой. Тесты:
-
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), PATCHplan.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. - FORM_1: AHR/Support, AHR/Development, CAP/Support, OPER/
-
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.
- Если
-
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).
-
test_patch_cells_two_branches_matches_get—PATCH /cellsпо строкам двух ROOT формы FORM_2/AHR.assert_patch_rows_match_sheet; в ответе обе INPUT; ключиrow_keyбез дублей. -
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(для узлов). -
test_patch_form1_development_matches_get— PATCH на FORM_1/AHR сdirection=Development,assert_patch_rows_match_sheetс GET того же направления. -
test_patch_otch9f_returns_all_months_of_equipment— PATCH месячного поступления (колонку взять из ветки OTCH9F_apply_form_cell) у строки'E1'месяца 1. Ответ = все строки(статья, 'E1'), остатки пересчитаны,assert_patch_rows_match_sheetс GET OTCH9F. -
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).
- Найти статью AHR глубины 3, у которой есть дочерняя статья глубины 4, и обе привязаны к FORM_2; id дочерней не занят в
-
test_ahr_delta_matches_get_after_rent_edit— форма 4: POST строки AHR_RENT, PATCHplan.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. -
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.
- Найти LEAF шаблона, у которого
- 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: Коммит
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: Обёртка миграции
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. Затем:
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:
$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: Коммит
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 — idlimit_template, для OTCH9F — idfixed_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 ограничивается разделами фокуса:
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: Коммит
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_1v_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:
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: Коммит
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: Коммит
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 заменён на
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: Коммит
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: Падающий тест
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: Коммит
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: Тесты
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: Коммит
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:
$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
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-контроллер)
-
Передать главному контроллеру проверку PG15. Сообщить ветку и коммит и попросить прогнать
pytest --init_dbнаdfip-pg15:55434(своя БДdfip_1850) — миграции с нуля до 0051 и весь набор. Исполнитель на PG15 не ходит. Ждать ответа. Падение миграции или новых тестов на PG15 — это нарушение корректности: стоп, правка, повторная передача. -
Финальное ревью всей ветки — модель 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. - дифф
-
PR без цифр (superpowers:finishing-a-development-branch):
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).
Не мержить.
-
Точка ожидания: замеры главного контроллера (design.md, «Замер до/после»; после основных ночных замеров). Исполнитель ничего не запускает на стенде и не трогает БД
perf. Контроллер записывает цифры в PR.- Шаги 3–4 замера (снимок GET, эквивалентность на данных стенда) разошлись — стоп и эскалация (корректность).
- Шаги 5–7 вышли за пороги — итерации только на пути правки, по порядку из Global Constraints («Стоп и итерации»). Каждая итерация — отдельный коммит в этой ветке, полный pytest и повторный замер контроллером. Недобор после шага 3 правила записывается в PR и уходит в follow-up.