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