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