DFiP_Budget_planing/docs/superpowers/plans/2026-10-28-aurora-1850-upd-form-cells-subtree.md
Andrey Epifancev 93ea5b5c42 docs(plan): aurora-1850 — план реализации
План в 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>
2026-11-01 10:47:27 +04:00

65 KiB
Raw Blame History

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:

#!/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: две статьи, которые попадают в ветку 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:

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).
  • 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: Коммит
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 — 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 ограничивается разделами фокуса:

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_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:

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-контроллер)

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

    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.