openspec: архив aurora-1848-sheet-json и aurora-1850-upd-form-cells-subtree

Обе задачи влиты в test 01.11 (PR #239/#242, #244/#274), архив пропущен.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 01:29:46 +04:00
parent fb45e8ddca
commit ac686991a8
13 changed files with 288 additions and 0 deletions

View File

@ -0,0 +1,43 @@
# realtime-sheet-updates Specification
## Purpose
Как редактируемая таблица фронта (лист формы, отчёт проекта) применяет входящие правки соавторов и эхо собственных правок к уже загруженным строкам и как она показывает значения, которых нет в строке. Цель — чтобы правки не терялись, даже если строки загружены без null-полей.
## Requirements
### Requirement: Входящая правка применяется к отсутствующему ключу
Когда приходит обновление строк — сообщение WebSocket `cell_updated` или ответ PATCH на собственную правку — фронт SHALL записать в найденную строку каждое поле из обновления, на любой глубине. Это делается и тогда, когда ключа этого поля или содержащего его блока в загруженной строке нет. Правка SHALL NOT теряться из-за того, что при загрузке листа поле было опущено.
#### Scenario: Соавтор заполняет пустую ячейку
- **WHEN** лист загружен без ключа `data.q1.<поле>` у строки (ячейка пустая), а соавтор вводит в эту ячейку значение
- **THEN** у пользователя ячейка показывает новое значение без перезагрузки листа
#### Scenario: Отсутствует весь блок
- **WHEN** в загруженной строке нет ключа блока `data.<блок>`, а приходит обновление с `data.<блок>.<поле>`
- **THEN** в строке появляется блок с этим полем, а ячейка показывает значение
#### Scenario: Прочие поля строки сохраняются
- **WHEN** приходит обновление одного поля строки
- **THEN** остальные поля строки сохраняют прежние значения
### Requirement: Очистка ячейки доходит до соавторов
Если в обновлении поле равно `null`, фронт SHALL записать `null`, и ячейка SHALL показываться пустой.
#### Scenario: Соавтор очищает ячейку
- **WHEN** у пользователя ячейка показывает значение, а соавтор очищает её
- **THEN** у пользователя ячейка становится пустой без перезагрузки листа
### Requirement: Отсутствующее значение показывается как null
Если ключа нет в `data`, редактируемая таблица, свод 1 и свод 2 SHALL показывать и обрабатывать значение так же, как явный `null`. Отображение: пустая ячейка в листе, «—» в сводах. Правила подсветки ошибок валидации не срабатывают. Поиск и фильтр по колонке не находят такую ячейку по непустому запросу. При входе в редактирование поле ввода пустое. Правка, которую фронт отправляет по WebSocket, SHALL всегда содержать ключ `value`; пустое значение передаётся как `null`, а не опускается.
#### Scenario: Выход из пустой ячейки без ввода
- **WHEN** пользователь входит в редактирование ячейки, ключа которой нет в строке, и выходит без ввода
- **THEN** сообщение `cell_updated` содержит `"value": null`, как и для ячейки с явным `null`, а пользователь не получает ошибку
#### Scenario: Пустая ячейка свода 2
- **WHEN** свод 2 загружен без ключа `data.q1.<поле>` у строки
- **THEN** ячейка показывает «—», как и для `null`
#### Scenario: Валидация не срабатывает
- **WHEN** у строки листа отсутствует ключ поля, к которому привязано правило валидации (например, остаток бронирования)
- **THEN** ячейка не подсвечивается как ошибочная

View File

@ -0,0 +1,113 @@
# sheet-cell-edit Specification
## Purpose
Ответ сервера на правку ячеек листа бюджетной формы (HTTP `PATCH /form/{id}/sheet/{sheet}/cell(s)` и WebSocket `cell_updated`): какие строки в нём приходят, какими они должны быть относительно полного GET листа и сколько времени правка может стоить на сервере.
## 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 % от числа отправленных правок

View File

@ -0,0 +1,132 @@
# sheet-read-api Specification
## Purpose
Контракт ответа тяжёлых GET с табличными данными: лист формы, своды 1 и 2, отчёты и сводные проектов РФ. Здесь описано, что получает фронт: обёртка, строки, отсутствие null-полей, служебный заголовок времени БД. Описаны и неизменные пути записи и выгрузки, и пороги производительности.
## Requirements
### Requirement: Обёртка и строки ответа тяжёлых GET
Следующие эндпоинты (далее — «тяжёлые GET») SHALL отвечать при успехе статусом 200, `Content-Type: application/json` и телом-объектом `{"success": true, "message": null, "result": [...], "count": N}`:
- `GET /api/v1/form/{form_id}/sheet/{sheet}`,
- `GET /api/v1/svod/1/{sheet}/{year}`,
- `GET /api/v1/svod/2/{sheet}/{year}`,
- `GET /api/v1/projects/{project_id}/report/{year}/{report_type}`,
- `GET /api/v1/rf-rollup/{branch_id}/{year}`,
- `GET /api/v1/projects/{project_id}/summary`,
- `GET /api/v1/projects/plan-summary`, `GET /api/v1/projects/fact-summary`, `GET /api/v1/projects/detail-summary`.
Каждый элемент `result` SHALL быть объектом с ключами `row_type` (строка), `depth` (целое), `sort_order` (целое или null) и `data` (объект). `count` SHALL равняться числу элементов `result`. Строки SHALL идти в том порядке, в каком их возвращает SQL-функция эндпоинта. Ответ SHALL сжиматься gzip так же, как остальные ответы API: при `Accept-Encoding: gzip` и размере от 1000 байт.
#### Scenario: Лист формы
- **WHEN** пользователь с доступом к форме запрашивает `GET /api/v1/form/{id}/sheet/AHR` для формы FORM_2
- **THEN** ответ 200, тело — объект с `success: true`, `message: null`, `result` — массив строк с ключами `row_type`, `depth`, `sort_order`, `data`, первая строка — `META` с `depth = -1`, `count` равен длине `result`
#### Scenario: Пустой свод
- **WHEN** пользователь без доступа ни к одной из запрошенных `org_ids` запрашивает `GET /api/v1/svod/2/GO/{year}?org_ids=...`
- **THEN** ответ 200 с телом `{"success": true, "message": null, "result": [], "count": 0}`
#### Scenario: Сжатие
- **WHEN** клиент запрашивает свод 2 с заголовком `Accept-Encoding: gzip`
- **THEN** ответ приходит с `Content-Encoding: gzip`, а после распаковки — тот же JSON
### Requirement: Значения ответа совпадают с прежними
После разбора JSON тяжёлые GET SHALL отдавать те же строки и те же значения, что отдавались до этого изменения при тех же данных, параметрах и пользователе. Единственное допустимое отличие — правило про null-поля (см. требование «Ответ листа и сводов без null-полей»). Текстовая запись чисел MAY отличаться (`1.50` вместо `1.5`), если после разбора значения равны. Порядок ключей в объектах и пробелы MAY отличаться.
Проверки доступа, ответы на ошибки (статус и тело) и обработка параметров (`direction`, `sections`, `org_ids`, `offset`, `limit`, `sort_by`, `sort_direction`, `branch_id`, `project_type`) SHALL остаться прежними.
#### Scenario: Сравнение до и после на стенде
- **WHEN** на одних и тех же данных стенда `perf/` сняты ответы всех листов, сводов 1 и 2 и отчётов проектов по манифесту — сначала на версии до изменения, затем после PR A
- **THEN** для каждого запроса результат `json.loads` ответа «после» равен результату `json.loads` ответа «до»
#### Scenario: Ошибки не изменились
- **WHEN** запрашивается лист несуществующей формы (`GET /api/v1/form/999/sheet/AHR`) или лист свода вне допустимого списка
- **THEN** статус и тело ответа такие же, как до изменения (404 с прежним `message`)
#### Scenario: Ограничение организаций в своде
- **WHEN** пользователь с ролью EXECUTOR_DFIP запрашивает свод с `org_ids`, часть которых ему не назначена
- **THEN** в ответе только строки назначенных ему организаций, как и до изменения
### Requirement: Ответ листа и сводов без null-полей
После PR B в ответах `GET /api/v1/form/{form_id}/sheet/{sheet}`, `GET /api/v1/svod/1/{sheet}/{year}` и `GET /api/v1/svod/2/{sheet}/{year}` объект `data` каждой строки, включая `META`, SHALL NOT содержать полей со значением `null` на любой глубине вложенности объектов. Объект, у которого все поля были `null`, SHALL остаться пустым объектом `{}`, а не исчезнуть. Элементы массивов, равные `null`, SHALL остаться. Ключи верхнего уровня строки (`row_type`, `depth`, `sort_order`, `data`) SHALL присутствовать всегда. Значения, отличные от `null`, SHALL совпадать с прежними.
Отчёты и сводные проектов (эндпоинты `/api/v1/projects/...` и `/api/v1/rf-rollup/...`) SHALL по-прежнему отдавать `null` явно.
#### Scenario: Пустая ячейка листа
- **WHEN** у строки INPUT листа FORM_2/AHR колонка `q1.<поле>` не заполнена и фронт запрашивает лист
- **THEN** в `data.q1` строки нет ключа этого поля, остальные поля `data.q1` присутствуют со своими значениями
#### Scenario: Сравнение до и после на стенде
- **WHEN** на одних и тех же данных сняты ответы всех листов и сводов 1 и 2 до изменения и после PR B
- **THEN** ответ «после» после `json.loads` равен ответу «до», из `data` которого рекурсивно удалены поля со значением `null`
#### Scenario: Отчёт проекта не меняется
- **WHEN** после PR B запрашивается `GET /api/v1/projects/{id}/report/{year}/LIMIT`
- **THEN** поля `data` со значением `null` присутствуют, как и до изменения
### Requirement: Ответы на правку отдают null явно
Ответы путей записи SHALL содержать поля со значением `null` явно, как до изменения: `PATCH /api/v1/form/{id}/sheet/{sheet}/cell`, `PATCH .../cells`, `POST .../line`, `DELETE .../line/{row_id}`, правки FORM_3 в `/api/v1/projects/...` и сообщения WebSocket (`cell_updated` и остальные с `result`). Очищенная ячейка SHALL приходить в ответе как ключ со значением `null`.
#### Scenario: Очистка ячейки
- **WHEN** пользователь отправляет `PATCH /api/v1/form/{id}/sheet/{sheet}/cell` со значением `null` для заполненной ячейки
- **THEN** в строке ответа есть ключ этой колонки со значением `null`
#### Scenario: Эхо соавтору
- **WHEN** пользователь очищает ячейку через WebSocket
- **THEN** соавторы получают `cell_updated`, где у этой колонки значение `null`, а не отсутствующий ключ
### Requirement: Выгрузка Excel не меняется
Выгрузки Excel листов, сводов 1 и 2 и отчётов проектов SHALL формировать то же содержимое, что и до изменения, при тех же данных и параметрах.
#### Scenario: Выгрузка свода 2
- **WHEN** после PR A и PR B выгружается свод 2 GO за год
- **THEN** содержимое файла (ячейки и значения) совпадает с выгрузкой до изменения
### Requirement: Заголовок X-DB-Time-Ms
Каждый успешный ответ тяжёлого GET SHALL содержать заголовок `X-DB-Time-Ms` — неотрицательное число миллисекунд с двумя знаками после точки. Это время от начала обработчика до получения строк из БД. Обработчик SHALL по-прежнему писать в лог запись `db_time` с тем же набором полей.
#### Scenario: Заголовок на ответе листа
- **WHEN** запрашивается `GET /api/v1/form/{id}/sheet/{sheet}`
- **THEN** в ответе есть заголовок `X-DB-Time-Ms`, его значение разбирается как число ≥ 0
#### Scenario: Методика api_split
- **WHEN** после изменения запускается `perf/analysis/api_split.py --scale 1`
- **THEN** для каждого запроса скрипт получает числовое `db_ms` (не `nan`)
### Requirement: Схема OpenAPI не меняется
В схеме OpenAPI (`/openapi.json`) у тяжёлых GET схема успешного ответа SHALL остаться `BaseListResponse[SheetResponse]`, как до изменения.
#### Scenario: Схема ответа листа
- **WHEN** запрашивается `/openapi.json`
- **THEN** у `GET /api/v1/form/{form_id}/sheet/{sheet}` схема ответа 200 ссылается на ту же компоненту, что и до изменения
### Requirement: Время ответа тяжёлых GET
На стенде `perf/` (масштаб 1, те же данные, напрямую на API, медиана 10 повторов после прогрева) после PR B полное время ответа SHALL снизиться относительно версии до изменения:
- `GET /api/v1/svod/2/GO/{year}` (все организации, роль dfip) — не менее чем на 30 %;
- `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 (роль rf) — не менее чем на 10 %. Дальнейшее ускорение этого листа — предмет AURORA-1847 (SQL листа).
После PR A полное время ни одного запроса из набора `api_split.py` SHALL NOT вырасти больше чем на 5 % от версии до изменения. Доля Python (`(полное − X-DB-Time-Ms) / полное`) — справочная метрика, порогом она не является.
Пороги этого требования и требования «Размер ответа листа FORM_2/AHR уменьшается» — критерии приёмки задачи, а не условие мержа PR. PR вливается по зелёным тестам и доказанной корректности (требования «Значения ответа совпадают с прежними» и «Ответ листа и сводов без null-полей»: сравнение ответов на стенде в режимах exact и strip-nulls). Корректность — жёсткое условие. Цифры замера дописываются в описание PR, недобор порога оформляется follow-up задачей.
#### Scenario: Замер свода 2 GO
- **WHEN** `perf/analysis/api_split.py --scale 1 --repeats 10` запускается на версии до изменения и после PR B на одних и тех же данных
- **THEN** медиана `total_ms` свода 2 GO «после» не больше 0,7 × медианы «до»
#### Scenario: Замер листа FORM_2/AHR
- **WHEN** тот же замер для листа FORM_2/AHR
- **THEN** медиана `total_ms` «после» не больше 0,9 × медианы «до»
#### Scenario: PR A не замедляет
- **WHEN** тот же замер на версии после PR A
- **THEN** медиана `total_ms` каждого запроса набора не больше 1,05 × медианы «до»
#### Scenario: Недобор порога
- **WHEN** тесты зелёные, сравнение ответов на стенде прошло, а замер показал недобор порога
- **THEN** PR вливается, цифры фиксируются в описании PR, недобор оформляется follow-up задачей
### Requirement: Размер ответа листа FORM_2/AHR уменьшается
После PR B распакованный размер ответа `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 на стенде (масштаб 1) SHALL быть меньше, чем до изменения (критерий Jira AURORA-1848; вместе с −10 % времени — критерий по этому листу, согласовано 28.10).
#### Scenario: Сравнение размеров
- **WHEN** `api_split.py --scale 1` запускается до изменения и после PR B
- **THEN** значение `bytes` листа FORM_2/AHR «после» меньше, чем «до»