Andrey Epifancev ac686991a8 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>
2026-11-12 01:29:46 +04:00

133 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 «после» меньше, чем «до»