Обе задачи влиты в test 01.11 (PR #239/#242, #244/#274), архив пропущен. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
14 KiB
Markdown
133 lines
14 KiB
Markdown
# 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 «после» меньше, чем «до»
|