Тяжёлые GET листов, сводов и отчётов проектов: готовый JSON из Postgres без pydantic-модели на строку (PR A) и без null-полей в data листа и сводов с правкой фронта (PR B). Требования — sheet-read-api и realtime-sheet-updates; дизайн, риски, тесты и сценарий замера — design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 KiB
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 снизиться не менее чем на 30 % относительно версии до изменения для:
GET /api/v1/svod/2/GO/{year}(все организации, роль dfip),GET /api/v1/form/{id}/sheet/AHRдля FORM_2 (роль rf).
После PR A полное время ни одного запроса из набора api_split.py SHALL NOT вырасти больше чем на 5 % от версии до изменения. Доля Python ((полное − X-DB-Time-Ms) / полное) — справочная метрика, порогом она не является.
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,7 × медианы «до»
Scenario: PR A не замедляет
- WHEN тот же замер на версии после PR A
- THEN медиана
total_msкаждого запроса набора не больше 1,05 × медианы «до»
Requirement: Размер ответа листа FORM_2/AHR уменьшается
После PR B распакованный размер ответа GET /api/v1/form/{id}/sheet/AHR для FORM_2 на стенде (масштаб 1) SHALL быть меньше, чем до изменения (критерий Jira AURORA-1848).
Scenario: Сравнение размеров
- WHEN
api_split.py --scale 1запускается до изменения и после PR B - THEN значение
bytesлиста FORM_2/AHR «после» меньше, чем «до»