# Spec Delta ## Purpose Контракт ответа тяжёлых GET с табличными данными: лист формы, своды 1 и 2, отчёты и сводные проектов РФ. Здесь описано, что получает фронт: обёртка, строки, отсутствие null-полей, служебный заголовок времени БД. Описаны и неизменные пути записи и выгрузки, и пороги производительности. ## ADDED 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 «после» меньше, чем «до»