Andrey Epifancev b701a4f5ff docs(openspec): aurora-1848-sheet-json — proposal, spec, design
Тяжёлые 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>
2026-10-28 20:24:23 +04:00

13 KiB
Raw Blame History

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