From b701a4f5ff085caea99b7be98d88216eceb8b676 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:24:23 +0400 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20aurora-1848-sheet-json=20?= =?UTF-8?q?=E2=80=94=20proposal,=20spec,=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тяжёлые 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 --- .../aurora-1848-sheet-json/.openspec.yaml | 2 + .../changes/aurora-1848-sheet-json/design.md | 212 ++++++++++++++++++ .../aurora-1848-sheet-json/proposal.md | 54 +++++ .../specs/realtime-sheet-updates/spec.md | 44 ++++ .../specs/sheet-read-api/spec.md | 127 +++++++++++ 5 files changed, 439 insertions(+) create mode 100644 openspec/changes/aurora-1848-sheet-json/.openspec.yaml create mode 100644 openspec/changes/aurora-1848-sheet-json/design.md create mode 100644 openspec/changes/aurora-1848-sheet-json/proposal.md create mode 100644 openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md create mode 100644 openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md diff --git a/openspec/changes/aurora-1848-sheet-json/.openspec.yaml b/openspec/changes/aurora-1848-sheet-json/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/aurora-1848-sheet-json/design.md b/openspec/changes/aurora-1848-sheet-json/design.md new file mode 100644 index 0000000..39f8aa1 --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/design.md @@ -0,0 +1,212 @@ +# Design: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей (AURORA-1848, #233) + +Мотивация и цифры — в proposal.md («Why»). Требования — в `specs/sheet-read-api/spec.md` и `specs/realtime-sheet-updates/spec.md`. Здесь описано, как их выполнить. + +## Context + +Ветка `test` @ 2a28748. + +**Путь ответа сейчас** (FastAPI 0.111, pydantic 2.11, SQLAlchemy 2 + asyncpg): +1. Роутер замеряет `t0` и вызывает сервис, сервис вызывает репозиторий, репозиторий выполняет `SELECT row_type, depth, sort_order, data FROM v3.<функция>(...)`. Для jsonb SQLAlchemy регистрирует в asyncpg кодек с `json.loads`, поэтому каждая строка `data` разбирается в dict ещё до возврата из `execute`. Этот разбор попадает в окно `X-DB-Time-Ms`. +2. Роутер ставит `response.headers["X-DB-Time-Ms"]` и пишет лог `db_time`. Затем строит `SheetResponse` на каждую строку: `_rows_to_sheet_response` в `forms.py:27-35` и `summary.py:29-37`, `_rows_to_payload` в `projects.py:60-64` (dict, но всё равно через `response_model`). +3. Аннотация возврата `-> BaseListResponse[SheetResponse]` становится `response_model`. FastAPI валидирует результат, сериализует его, `JSONResponse` делает `json.dumps`, `GZipMiddleware(minimum_size=1000, compresslevel=1)` сжимает (`main.py:248`). + +**Затронутые обработчики:** + +| эндпоинт | роутер | сервис → репозиторий → SQL | +|---|---|---| +| GET `/form/{id}/sheet/{sheet}` | `forms.py:150-202` `get_sheet` | `SheetService.get` → `SheetRepository.get` (`sheet_repository.py:33-68`) → `v3.v_form_view(form, sheet, sections[, direction])` | +| GET `/svod/1/{sheet}/{year}` | `summary.py:50-90` | `SummaryService.get_svod1_rows` (проверка орг. `_allowed_org_ids`) → `SummaryRepository.get_svod1_rows` → `v3.v_summary_view` | +| GET `/svod/2/{sheet}/{year}` | `summary.py:93-146` | `get_svod2_rows` → `v3.v_svod2_go` / `v3.v_svod2_rf` (row_type и depth — константы в SELECT) | +| GET `/projects/{id}/report/{year}/{type}` | `projects.py:242-277` | `ProjectService.get_report_rows` (`resolve_report_id`) → `v3.v_form3_report_jsonb` | +| GET `/rf-rollup/{branch}/{year}` | `projects.py:280-312` | `get_rf_rollup_rows` → `v3.v_form3_rf_rollup_jsonb` | +| GET `/projects/{id}/summary` | `projects.py:315-338` | `get_project_summary_rows` → `v3.v_project_summary_jsonb` | +| GET `/projects/{plan,fact,detail}-summary` | `projects.py:142-226` + `_get_projects_summary_response` `:117-139` | `get_*_summary_rows` → `v3.v_project_{plan,fact,detail}_summary_jsonb` | + +У всех функций столбец `data` имеет тип `jsonb`. + +**Кто ещё читает те же данные** и не должен измениться: +- выгрузка Excel: `export_service.py:219, 236` — `SummaryService.get_svod1_rows/get_svod2_rows`; `:438` — `SheetService.get`; `:484, :561` — `ProjectService.get_report_rows`; +- WebSocket (`websocket.py`) целые листы через эти методы не читает. Он вызывает пути записи (`update_cell`, `add_line`, …), и они здесь не меняются; +- инструменты `perf/` читают GET листа: `perfkit/coauthor.py:52, :73`, `perfkit/gen/grow.py:45-67`, `perfkit/gen/run.py:248, 304, 467, 504-506`. + +**Фронт.** Начальная загрузка листа и отчёта проекта — `useRealtimeData.js:36-58`, в обоих случаях потом используется `buildRowHierarchy`. Входящие правки идут через `updateCells` (`cellUtils.js:46-100`). На `test` правка вливается в блок `data[key]`, только если `row.data[key] !== undefined` (`:79`), иначе она молча теряется. PR #223 (AURORA-1838, открыт, не в `test`) переписывает `updateCells`: отсутствующий ключ вставляется (`mergeIntoRow`, ветка `else if (hasData)`), а глубже `mergeImmutable` и раньше создавал недостающие узлы. + +## Goals / Non-Goals + +**Goals:** +- Тело тяжёлых GET собирается из текста, который вернул Postgres, без разбора в dict и без модели на строку (PR A). +- `data` листа и сводов без null-полей, фронт не теряет правки соавторов (PR B). +- Доказательства: сравнение ответов до и после на стенде, тесты эквивалентности, замер `api_split.py`. + +**Non-Goals:** +- Менять SQL-функции, данные в `data` (это делает AURORA-1846) и SQL листа FORM_2/AHR (это AURORA-1847). +- Постраничная загрузка свода 2 и листов (М10), смена уровня gzip, переход на orjson или другой сериализатор для остальных эндпоинтов. +- Менять ответы путей записи и сообщения WebSocket, выгрузку Excel. +- Убирать null из отчётов проектов (только PR A). + +## Decisions + +### Решения человека (согласовано 28.10) +1. GET листов и сводов потребляет только наш фронт. Выгрузка Excel и WebSocket идут своими путями (через репозитории и сервисы) и не меняются. Старые методы репозиториев для них остаются. +2. Два PR. **A** — готовый JSON из Postgres (`jsonb::text`) без pydantic-модели на строку. Ответ по смыслу тот же, это доказывается сравнением ответов всех листов и сводов на стенде до и после. **B** — без null-полей в `data` (`jsonb_strip_nulls`) и правка фронта, чтобы входящие правки соавторов по отсутствующим ключам не терялись. В B проверить, что `updateCells` из AURORA-1838 (PR #223) для этого достаточно, и покрыть тестом. B стеком на A. +3. Ответ на правку ячейки (PATCH `/cell(s)` и WebSocket `cell_updated`) продолжает отдавать `null` явно: очистка ячейки должна дойти до соавторов. +4. Отчёты проектов РФ (`projects.py`, `SheetResponse`) — тем же хелпером, отдельным коммитом в A. +5. Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 %. «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок `X-DB-Time-Ms` должен сохраниться: на нём держится методика `perf/analysis/api_split.py`. +6. `response_model` в декораторах остаётся ради OpenAPI. + +### D1. Строка ответа собирается в SQL функцией `json_build_object` +Новый метод репозитория выполняет: +```sql +SELECT json_build_object( + 'row_type', row_type, 'depth', depth, + 'data', data, -- PR B: jsonb_strip_nulls(data) + 'sort_order', sort_order)::text +FROM v3.<функция>(<те же аргументы, что в старом методе>) +``` +Для свода 2 `row_type` и `depth` — те же константы, что в старом SELECT (`'INPUT'`, `3`). +- `json_build_object` (тип `json`) сохраняет порядок ключей обёртки строки. Аргумент jsonb он выводит своим текстовым представлением, то есть `data` сериализуется один раз. +- `::text` даёт тип `text`, и кодек `json.loads` у asyncpg не срабатывает. +- Порядок строк — порядок вывода функции, без `ORDER BY`, как сейчас. + +Отвергнуто: +- `jsonb_build_object(...)::text`: пересобирает `data` во внутреннее представление jsonb и сортирует ключи обёртки (`data, depth, row_type, sort_order`). Это лишняя работа на 30–50 МБ. +- `format('{"row_type":%s,...}', ...)`: хрупко (экранирование строк, `NULL` → пустая подстановка), а выигрыш только в пробелах. +- `string_agg` всего ответа в одну строку в SQL: выигрыш на тысячах строк ничтожен, а `count` и порядок придётся выражать отдельно. +- Сериализация в Python через orjson без моделей: остаётся `json.loads` в драйвере, и нужна новая зависимость. + +### D2. Новые методы рядом со старыми +- Репозитории: `SheetRepository.get_json`, `SummaryRepository.get_svod1_json` / `get_svod2_json`, `ProjectRepository.get_report_json`, `get_rf_rollup_json`, `get_project_summary_json`, `get_{plan,fact,detail}_summary_json`. Параметр `strip_nulls: bool = False` добавляется в PR B только к методам листа и сводов. Возврат — `list[str]`. +- Сервисы: методы `*_json` с теми же проверками, что у старых (`_allowed_org_ids`, `_allowed_summary_branch_ids`, `resolve_report_id`, `get_instance`, `AccessDeniedException`, ранний `[]` при пустом списке организаций). +- Старые методы (`get`, `get_svod1_rows`, `get_svod2_rows`, `get_report_rows`, …) **не меняются ни в одной строке**: их используют выгрузка и сравнительные тесты. +- Дублирование FROM-части устраняет исполнитель: вынести построение FROM-фрагмента и параметров в приватную функцию, которую вызывают и старый, и новый метод. Текст SQL старого метода после выноса должен остаться эквивалентным. Валидация `SheetRepository.get` (`:40-49`, `SheetValidationError`) обязательна и в `get_json`. + +### D3. Хелпер ответа и роутеры +Новый модуль `api/src/api/v1/json_response.py`: +```python +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + body = b'{"success":true,"message":null,"result":[' + ",".join(rows).encode() + b'],"count":' + str(len(rows)).encode() + b"}" + return Response(content=body, media_type="application/json", headers=headers) +``` +Точная форма кода — на усмотрение исполнителя. Инварианты: обёртка ровно как у `BaseListResponse` при успехе (`success`, `message`, `result`, `count`), UTF-8, `media_type="application/json"` (без `charset`, как у `JSONResponse`). + +Роутеры (`get_sheet`, `get_summary`, `get_summary2`, шесть GET в `projects.py`): +- аннотация возврата меняется на `Response`, а `response_model=BaseListResponse[SheetResponse]` переносится в декоратор `@router.get(...)`. Схема OpenAPI остаётся прежней (требование «Схема OpenAPI не меняется»); +- **`X-DB-Time-Ms` ставится на возвращаемый `Response`** (`headers=`). Заголовки, записанные в инъецированный параметр `response: Response`, FastAPI переносит только при возврате не-Response значения. Параметр `response` из сигнатуры убрать; +- окно замера то же: от `t0` до возврата строк из сервиса. Лог `db_time` с прежними полями; +- проверки до запроса (404 формы и листа, `_validate_sheet_query_params`, недопустимые `sections`, список листов свода) не меняются; +- `_rows_to_sheet_response` в `forms.py` остаётся: её используют PATCH, POST и DELETE. В `summary.py` она становится неиспользуемой и удаляется. `_rows_to_payload` в `projects.py` остаётся для правок FORM_3. + +`StreamingResponse` не используется (см. «Decisions made without discussion»). + +### D4. PR B: `jsonb_strip_nulls` для листа и сводов +- В `get_json`, `get_svod1_json`, `get_svod2_json` выражение `data` заменяется на `jsonb_strip_nulls(data)`. Роутеры листа и сводов вызывают методы со `strip_nulls=True`. Отчёты проектов — нет. +- Семантика PG 15: функция рекурсивно удаляет поля объектов со значением `null`, в том числе в объектах внутри массивов. Элементы массивов, равные `null`, остаются, пустые объекты `{}` остаются. Строка META (`editable[].closes_at = null`) тоже теряет null-поля, фронт читает из неё только `editable[].column` (`useRealtimeData.js:49-50`). +- Обёртка строки (`row_type`, `depth`, `sort_order`) не трогается: `sort_order` может остаться `null`. + +### D5. PR B: фронт +1. **`updateCells`.** Предусловие B — PR #223 смержен в `test`, и ветка B включает его (rebase на `test` после merge A и #223). B проверяет, что ветка `else if (hasData)` в `mergeIntoRow` и `mergeImmutable(undefined, …)` вставляют отсутствующий блок и отсутствующий лист, и добавляет тесты в `web/src/components/RealtimeTable/utils/cellUtils.test.js`: + - строка без блока `q1`, приходит `{q1: {m1: 5}}` → `row.data.q1.m1 === 5`; + - блок `q1` есть, но без `m1`, приходит `{q1: {m1: 5}}` → значение вставлено, остальные поля `q1` сохранены; + - строка с `q1.m1 = 5`, приходит `{q1: {m1: null}}` → `row.data.q1.m1 === null`; + - отсутствующий скалярный ключ верхнего уровня `data` (например, `line_id`) вставляется. + + Если какой-то тест падает на версии #223, правка вносится в `cellUtils.js` в B. +2. **Отправка правки с отсутствующим значением (найдено при сверке).** `EditCell.handleSave` (`EditCell.jsx:117-148`) при выходе из редактирования всегда вызывает `onChange(normalizeNumericInput(currentValue))`, а `RealtimeContext.updateCell` (`RealtimeContext.js:304-330`) кладёт значение в `data.value`. Для отсутствующего ключа `currentValue === undefined`, `JSON.stringify` опускает `value`, и сервер падает на `event_data["value"]` (`websocket.py:472`, для FORM_3 — `:703`): пользователь получит ошибку там, где раньше уходил `null`. Исправление на фронте: в `updateCell` нормализовать `undefined` в `null` (`value: value === undefined ? null : value`), чистую функцию вынести в `.js` и покрыть тестом. Сервер не трогаем (решение 1: WebSocket не меняется). +3. **Аудит отличий `null` от `undefined`** на страницах листа, отчёта проекта, свода 1 и свода 2. При сверке проверено: `validation.js:94-209`, `Cell.jsx:149-156`, `tableColumns.jsx:88`, `SummaryTable.jsx:25, 71-72`, `Svod2Page.jsx:38-39, 354-365`, `SvodPage.jsx:53-54`, `RealtimeTable.jsx:48-57`, `rowUtils.js` — все обрабатывают и `undefined`. Сортировка на клиенте в сводах выключена (`enableSorting: false`). Проверок `in` / `hasOwnProperty` по `data` нет. Исполнитель повторяет поиск (`=== null`, `!== null`, `typeof … === 'object'`, `in `, `hasOwnProperty`, `Object.keys/entries` по `data`) по `web/src` на ветке B, потому что за время работы в `test` могли влиться другие PR, и фиксирует результат в описании PR. +4. **Потребители в `perf/`.** `perfkit/gen/grow.py:67` (`r["data"]["header"]["project_id"]` для ITEM) и аналогичные обращения через `[...]` к полям, которые могут быть `null`, заменить на `.get(...)`, чтобы генератор стенда не падал на ответах B. + +### D6. Тесты +**Unit (`api/tests/unit/test_json_list_response.py`, PR A):** +- тело хелпера разбирается `json.loads` в `{"success": True, "message": None, "result": [...], "count": n}`; +- пустой список, одна строка, кириллица и экранированные символы в строке; +- `media_type`, переданные заголовки. + +**Интеграционные (`api/tests/integration/test_heavy_get_json.py`, PR A, дополняются в B).** Эталон — старый путь в той же сессии: `client.portal.call(...)` поверх `SheetRepository(db_session).get(...)`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, превращённый в `[{"row_type", "depth", "sort_order", "data"}]`. Так же устроен тест AURORA-1846, см. ниже про `db_session`. +- лист: FORM_1 AHR Support (форма 1 фикстуры), лист без `direction`, вариант с `sections`. `response.json()["result"] == эталон` (A) или `== strip_nulls(эталон)` (B). `count == len(result)`; +- свод 1 по всем `VALID_SUMMARY_SHEETS`, свод 2 GO/RF без параметров, с `org_ids`, с `offset/limit`, с `sort_by/sort_direction`; +- отчёты проектов (A): проект создаётся в тесте, как в `test_projects_api_smoke.py`, сравниваются report, project summary и plan/fact/detail-summary. В B — проверка, что `null` в отчётах остались; +- заголовок `X-DB-Time-Ms` есть и разбирается как число на всех девяти эндпоинтах; +- роль: `isp_tokens` получает свод только по своим организациям, как и через старый путь; +- OpenAPI: у девяти путей схема ответа 200 ссылается на ту же компоненту, что в эталонном снимке. Снимок снимается на базовом коммите и кладётся в тест как литерал (имя компоненты); +- B: PATCH ячейки со значением `null` → в строке ответа ключ присутствует со значением `null` (требование «Ответы на правку отдают null явно»); +- рекурсивный эталон `strip_nulls` на Python пишется в тесте так же, как работает PG: удаляются поля объектов, `null` в массивах остаются, `{}` остаётся. + +Сравнение чисел: эталон приходит через `json.loads` драйвера (float), новый ответ — через `response.json()` (float). Равенство точное. + +Фикстура `db_session`. AURORA-1846 (PR #232) добавляет её в `api/tests/integration/conftest.py` (`client` начинает зависеть от `db_session`). Если к началу работы #232 не в `test`, внести в A **тот же самый хунк**: при одинаковых правках git сливает без конфликта. + +Существующие тесты: `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_db_functions_integration.py:79-93` должны проходить в A без изменений. В B `test_summary2_sheets_smoke` проверяет наличие ключей `header.smeta_direction` и блоков `data`. Если у первой строки эти значения `null`, тест меняется так: проверять ключи только у строк, где значение не `null`, или проверять набор ключей по эталону со `strip_nulls`. Это ожидаемое изменение контракта, в PR его нужно оговорить. + +**Фронт (B):** `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`. + +**Ручная проверка (B, чек-лист PR):** +- на FORM_2/AHR второй пользователь заполняет пустую ячейку, первый видит значение без перезагрузки; +- то же с очисткой; +- вход в пустую ячейку и выход без ввода — без ошибки; +- свод 2: пустые ячейки показывают «—», поиск работает; +- отчёт проекта не изменился. + +Сценарий соавтора на стенде — после ночного прогона: `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0`. + +### D7. Замер и сравнение на стенде +Стенд не трогать до окончания ночного прогона. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`. + +Данные: масштаб 1, одна и та же БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией). Миграции «до» и «после» одинаковые: у A и B миграций нет. Если между замерами в `test` влилась миграция (1846, 1844), серия «до» переснимается на новой базе. + +1. **Сравнение ответов** — новый скрипт `perf/analysis/compare_responses.py` (PR A): + - `dump --scale 1 --out ` обходит по `results/manifest-s1.json` все листы (все `owner`, с `direction` где есть, плюс один вариант с `sections` для FORM_2/AHR), своды 1 (`FORM_1..4`, `MAIN`) и 2 (`GO`, `RF`) за год манифеста, отчёты и сводные проектов. Запросы идут под ролью из манифеста (`rf` для своих листов, `dfip` для сводов), ответы сохраняются как `.json.gz` по имени запроса; + - `compare --mode exact|strip-nulls` сравнивает `json.loads` ответов. В режиме `strip-nulls` сравниваются `after` и рекурсивно очищенный `before`, но только для листов и сводов, отчёты проектов сравниваются точно. Вывод — число совпавших и список расхождений с путём до первого отличия, код возврата ≠ 0 при расхождении. + + Порядок: `dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` (с дампом «до»). +2. **Время.** `cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/-1848-/analysis` три раза: на базовом коммите, на A, на B. API перезапускается на каждом коммите. Метрики: + - `total_ms` свода 2 GO и листа FORM_2/AHR: порог «B ≤ 0,7 × до»; + - `total_ms` всех запросов набора: «A ≤ 1,05 × до»; + - `bytes` FORM_2/AHR: «B < до»; + - справочно `python_share` и новое поле `wire_bytes`. +3. **`api_split.py`, PR A:** добавить поле `wire_bytes` (`r.num_bytes_downloaded`, размер на проводе) и колонку в `.md`. Существующие поля и их смысл не меняются. +4. Ссылка на `X-DB-Time-Ms` в docstring `api_split.py` (номера строк `forms.py:184` и др.) обновляется на новые места. + +Оценка (не замер): разбор jsonb драйвером синтетически ≈ 8 мс/МБ. Это ≈ 60 мс из 726 мс `X-DB-Time-Ms` листа FORM_2/AHR и ≈ 240 мс из 718 мс свода 2 GO. + +## Risks / Trade-offs + +- **[Порог 30 % для FORM_2/AHR, вероятно, недостижим этой задачей]** По замеру 26.10: полное время 813 мс, `X-DB-Time-Ms` 726 мс, остаток 87 мс. Эта задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше). Ожидаемое снижение — порядка 12–20 %, для 30 % нужно ≤ 569 мс. Основное время — SQL `v_form_view` (М7, AURORA-1847). Для свода 2 GO порог реален: ≈ 240 мс разбора + большая часть 339 мс остатка. → Это вопрос человеку (см. отчёт автора дизайна). До ответа спека фиксирует согласованный порог. Замер даёт отдельной строкой полное время и разложение, чтобы решение принималось по цифрам. +- **[`jsonb::text` пишет `": "` и `", "`]** В A распакованный размер может вырасти примерно на 10–14 %, gzip уровня 1 обрабатывает больше байт. → Порог A — «не медленнее 5 %». Уменьшение размера даёт только B (критерий Jira). +- **[Ответ в обход `response_model` не валидируется]** Ошибка формы ответа не будет поймана FastAPI. → Тесты эквивалентности со старым путём на всех девяти эндпоинтах, проверка OpenAPI, сравнение на стенде. +- **[Заголовок теряется при возврате `Response`]** → Заголовок ставится на возвращаемый объект, тест на всех эндпоинтах, `api_split.py` падает на `nan` (сценарий в спеке). +- **[Потеря правок соавторов в B]** → Предусловие #223, тесты `cellUtils.test.js`, ручной сценарий. B не мержится раньше #223. +- **[`value` пропадает из сообщения WebSocket в B]** → Нормализация `undefined → null` в `updateCell` (D5.2) и тест. +- **[Скрытые проверки `=== null` на фронте]** → Аудит (D5.3) с перечнем проверенного, ручной чек-лист. +- **[В dev-сборке TanStack Table пишет `console.warn` для `undefined` во вложенном `accessorKey`]** В prod-сборке этого нет. Для `null`-блоков предупреждение появлялось и раньше. → Не исправляем, отмечаем в PR B. +- **[Выгрузка или WebSocket случайно переходят на новые методы]** → Старые методы не меняются. В ревью — дифф `export_service/` и `websocket.py` должен быть пустым (кроме D5.2, это фронт). Интеграционные тесты выгрузки остаются зелёными. +- **[Пиковая память]** Тело ответа 30–50 МБ (масштаб 2 — до ~200 МБ) собирается одним `bytes`, плюс список строк того же объёма. Это меньше, чем дерево dict, pydantic-модели и строка `json.dumps` сейчас. → Приемлемо, стриминг не нужен. +- **[Конфликты с параллельными PR]** `conftest.py` с #232 (одинаковый хунк), `cellUtils.js` с #223 (B после #223). Миграций нет, конфликта номеров 0050 у этого change нет. + +## Migration Plan + +- Порядок: PR A → merge в `test` → PR B (ветка от A, rebase на `test` после merge A и #223) → merge. +- Миграций БД нет, деплой — обычный выкат API и фронта. B выкатывается одновременно с фронтом B, потому что это одна сборка (фронт отдаёт API). +- Откат: revert PR B возвращает null-поля, фронт B совместим и с ними. Revert PR A возвращает модели. Данные не затрагиваются. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-28 | Строка собирается `json_build_object(...)::text`, а не `jsonb_build_object` и не `format()` (D1) | Порядок ключей обёртки сохраняется, `data` сериализуется один раз, нет ручного экранирования | Низкая: замена выражения в одном SQL-фрагменте | +| 2026-10-28 | Ответ собирается из списка строк (по строке на ряд) в Python, а не одной `string_agg` в SQL | `count` и порядок как сейчас, проще эквивалентность; разница в стоимости ничтожна при тысячах строк | Низкая | +| 2026-10-28 | Обычный `Response`, без `StreamingResponse` | Сессия БД не держится открытой на время отправки; GZipMiddleware и Content-Length работают как раньше; память ниже текущей | Средняя только на масштабе 2+ (пиковая память); переделка локальна в хелпере | +| 2026-10-28 | Имена новых методов `*_json` и параметр `strip_nulls` в репозиториях листа и сводов | Явная пара «старый метод для выгрузки / новый для GET»; одно место включения B | Низкая: переименование | +| 2026-10-28 | «Отчёты проектов РФ (projects.py, SheetResponse)» понято как все шесть GET в `projects.py` с `BaseListResponse[SheetResponse]`: report, rf-rollup, project summary, plan/fact/detail-summary | Все объявляют `SheetResponse` и строятся одинаково | Низкая: лишние 2–3 эндпоинта в отдельном коммите, их легко исключить | +| 2026-10-28 | В PR B null убираются только у листа и сводов 1/2; отчёты проектов отдают null как раньше | Человек отнёс отчёты проектов к A; их ответ мал (1,5 МБ), а проверять фронт проектов отдельно — лишний объём | Недополученный выигрыш на отчётах проектов; можно сделать follow-up | +| 2026-10-28 | `jsonb_strip_nulls` применяется ко всем строкам ответа, включая META | Единое правило; фронт читает из META только `editable[].column` | Низкая: если что-то читает null-поля META, исключить её одним условием | +| 2026-10-28 | Порог 30 % проверяется на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе `api_split.py`» | Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа | Если порог нужен уже на A — B станет обязательным для приёмки A | +| 2026-10-28 | Критерий Jira «размер ответа FORM_2/AHR уменьшился» включён в спеку как требование к B | Он есть в приёмке Jira, человек его не отменял | Низкая | +| 2026-10-28 | В `api_split.py` добавляется поле `wire_bytes` (размер на проводе), прежние поля не меняются | Эффект B на сжатый размер иначе не виден | Низкая | +| 2026-10-28 | Новый скрипт `perf/analysis/compare_responses.py` (dump/compare) в PR A | Доказательство эквивалентности на всех листах и сводах стенда требует повторяемой команды | Низкая | +| 2026-10-28 | Фикстура `db_session` вносится тем же хунком, что в AURORA-1846, если #232 ещё не в `test` | Тесту эквивалентности нужен старый путь в той же сессии; одинаковый хунк сливается без конфликта | Низкая: ручное слияние conftest | +| 2026-10-28 | PR B не мержится раньше PR #223 (AURORA-1838); при задержке #223 B ждёт, а не дублирует `updateCells` | Две реализации `updateCells` дадут конфликт и двойное ревью | Задержка B до #223 | +| 2026-10-28 | Пустое значение правки нормализуется в `null` на фронте (`updateCell`), сервер WebSocket не меняется (D5.2) | Человек зафиксировал «WebSocket не меняется»; дефект проявляется только из-за B | Если есть другой клиент WebSocket, он сохранит старое поведение (его нет) | +| 2026-10-28 | B правит потребителей GET в `perf/perfkit` (`[...]` → `.get`) для полей, которые могут исчезнуть | Генератор и бот стенда — наши инструменты замера; падение сорвёт повторный прогон | Низкая | +| 2026-10-28 | В B тест `test_summary2_sheets_smoke` ослабляется для ключей с `null`, если он на них падает | Это прямое следствие нового контракта | Низкая | +| 2026-10-28 | `_rows_to_sheet_response` в `summary.py` удаляется, в `forms.py` остаётся (её используют пути записи) | Мёртвый код | Нет | diff --git a/openspec/changes/aurora-1848-sheet-json/proposal.md b/openspec/changes/aurora-1848-sheet-json/proposal.md new file mode 100644 index 0000000..ef954ad --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/proposal.md @@ -0,0 +1,54 @@ +# Proposal: тяжёлые GET листов и сводов — готовый JSON без pydantic-модели на строку и без null-полей + +Jira: AURORA-1848 (эпик AURORA-1834). Gitea: #233. Источник цифр: `perf/REPORT.md`, разделы М9 и М10, и `perf/report-data/20261026-s1/analysis/api-split.md` (масштаб 1, медиана 10 повторов, напрямую на :8000). + +## Why + +Тяжёлые GET (лист формы, свод 1, свод 2) отдают тысячи строк, в каждой сотни полей, большинство из них `null`. Ответ проходит через Python 4–5 раз: драйвер разбирает jsonb в dict (`json.loads` внутри окна `X-DB-Time-Ms`), роутер строит `SheetResponse` на каждую строку (`api/src/api/v1/forms.py:27-35`, `summary.py:29-37`), FastAPI валидирует `response_model`, сериализует в JSON, затем `GZipMiddleware` сжимает (`api/src/main.py:248`). + +| запрос (масштаб 1) | полное, мс | `X-DB-Time-Ms` | остаток | доля остатка | размер | +|---|---|---|---|---|---| +| свод 2 GO, 5 563 строки | 1 057 | 718 | 339 | 32 % | 30,6 МБ | +| свод 2 RF, 9 497 строк | 1 808 | 1 229 | 578 | 32 % | 52,0 МБ | +| лист FORM_2/AHR, 2 092 строки | 813 | 726 | 87 | 11 % | 7,6 МБ | +| отчёт проекта LIMIT, 1 364 строки | 343 | 307 | 36 | 11 % | 1,5 МБ | + +Сами данные уже собраны в Postgres как jsonb. Разбирать их в Python, чтобы тут же собрать обратно, — лишняя работа. Явные `null` раздувают ответ и время разбора в браузере: на масштабе 2 свод 2 — 123–195 МБ. + +## What Changes + +Два PR, B стеком на A (согласовано 28.10). + +**PR A — готовый JSON из Postgres, ответ по смыслу тот же.** +- `GET /api/v1/form/{form_id}/sheet/{sheet}`, `GET /api/v1/svod/1/{sheet}/{year}`, `GET /api/v1/svod/2/{sheet}/{year}` отдают тело, собранное из текста строк, который вернул Postgres (`json_build_object(...)::text`). Нет pydantic-модели на строку и нет валидации `response_model`. Обёртка `{"success","message","result","count"}` прежняя. +- Отдельным коммитом то же для GET отчётов проектов в `api/src/api/v1/projects.py`, которые объявляют `BaseListResponse[SheetResponse]`. +- После разбора JSON значения те же, что и раньше. Байты могут отличаться: пробелы и запись чисел (`1.50` вместо `1.5`). +- Заголовок `X-DB-Time-Ms` сохраняется на всех этих ответах. `response_model` остаётся в декораторах ради OpenAPI. +- `perf/`: скрипт сравнения ответов до и после, плюс размер ответа на проводе в `api_split.py`. + +**PR B — без null-полей в `data` + правка фронта.** +- В ответах GET листа, свода 1 и свода 2 у объектов в `data` нет полей со значением `null`, на любой глубине (`jsonb_strip_nulls`). **BREAKING** для потребителя, который отличает «ключ есть и равен null» от «ключа нет». Единственный такой потребитель — наш фронт. Выгрузка Excel и WebSocket идут своими путями и не меняются. +- Фронт: входящая правка соавтора (WebSocket `cell_updated`, эхо PATCH) применяется и к ключу, которого нет в загруженной строке. В AURORA-1838 (PR #223) `updateCells` уже вставляет отсутствующий ключ. B проверяет, что этого достаточно, и закрепляет поведение тестом. + +**Что НЕ меняется** +- Ответы на правку — PATCH `/cell`, `/cells`, POST/DELETE `/line`, WebSocket `cell_updated` и правки FORM_3 — по-прежнему отдают `null` явно: очистка ячейки должна дойти до соавторов. +- Выгрузка Excel и WebSocket читают данные через прежние методы репозиториев и сервисов. Эти методы остаются без изменений. +- SQL-функции и миграции. Новой миграции нет. +- Отчёты проектов в PR B: в них `null` остаются. +- Сжатие (`GZipMiddleware`, уровень 1), постраничная загрузка (М10). + +## Capabilities + +### New Capabilities +- `sheet-read-api`: контракт ответа тяжёлых GET (лист, своды 1 и 2, отчёты проектов) — обёртка, строки, отсутствие null-полей, заголовок `X-DB-Time-Ms`, схема OpenAPI, неизменность путей записи и выгрузки, критерии производительности. +- `realtime-sheet-updates`: как фронт применяет входящие правки соавторов к строкам, загруженным без null-полей, и как показывает отсутствующие значения. + +### Modified Capabilities +Нет: `openspec/specs/` пуст. + +## Impact + +- Бэк: `api/src/api/v1/forms.py`, `summary.py`, `projects.py` (только GET), `api/src/repository/sheet_repository.py`, `summary_repository.py`, `project_repository.py`, `api/src/services/sheet_service.py`, `summary_service.py`, `project_service.py` (новые методы рядом со старыми), новый хелпер ответа в `api/src/api/v1/`, тесты в `api/tests/unit` и `api/tests/integration`. +- Фронт (PR B): `web/src/components/RealtimeTable/utils/cellUtils.js` (если #223 не покрывает всё), тесты `cellUtils.test.js`. +- `perf/`: `analysis/api_split.py` (новое поле), новый `analysis/compare_responses.py`. В PR B — потребители GET в `perf/perfkit`, если они обращаются к ключам, которые стали отсутствовать. +- Связь с задачами: AURORA-1838 (PR #223) — B без неё не мержится. AURORA-1844/1850 — контракт строк ответа на правку с явными `null`, этот change его не трогает. AURORA-1846 (PR #232) меняет содержимое `data` FORM_4, этот change — упаковку; общий участок — фикстура `db_session` в `api/tests/integration/conftest.py`. AURORA-1847 — SQL листа FORM_2/AHR, от него зависит, достижим ли порог времени для этого листа (см. design.md, «Риски»). diff --git a/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md b/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md new file mode 100644 index 0000000..7f0822b --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md @@ -0,0 +1,44 @@ +# Spec Delta + +## Purpose + +Как редактируемая таблица фронта (лист формы, отчёт проекта) применяет входящие правки соавторов и эхо собственных правок к уже загруженным строкам и как она показывает значения, которых нет в строке. Цель — чтобы правки не терялись, даже если строки загружены без null-полей. + +## ADDED Requirements + +### Requirement: Входящая правка применяется к отсутствующему ключу +Когда приходит обновление строк — сообщение WebSocket `cell_updated` или ответ PATCH на собственную правку — фронт SHALL записать в найденную строку каждое поле из обновления, на любой глубине. Это делается и тогда, когда ключа этого поля или содержащего его блока в загруженной строке нет. Правка SHALL NOT теряться из-за того, что при загрузке листа поле было опущено. + +#### Scenario: Соавтор заполняет пустую ячейку +- **WHEN** лист загружен без ключа `data.q1.<поле>` у строки (ячейка пустая), а соавтор вводит в эту ячейку значение +- **THEN** у пользователя ячейка показывает новое значение без перезагрузки листа + +#### Scenario: Отсутствует весь блок +- **WHEN** в загруженной строке нет ключа блока `data.<блок>`, а приходит обновление с `data.<блок>.<поле>` +- **THEN** в строке появляется блок с этим полем, а ячейка показывает значение + +#### Scenario: Прочие поля строки сохраняются +- **WHEN** приходит обновление одного поля строки +- **THEN** остальные поля строки сохраняют прежние значения + +### Requirement: Очистка ячейки доходит до соавторов +Если в обновлении поле равно `null`, фронт SHALL записать `null`, и ячейка SHALL показываться пустой. + +#### Scenario: Соавтор очищает ячейку +- **WHEN** у пользователя ячейка показывает значение, а соавтор очищает её +- **THEN** у пользователя ячейка становится пустой без перезагрузки листа + +### Requirement: Отсутствующее значение показывается как null +Если ключа нет в `data`, редактируемая таблица, свод 1 и свод 2 SHALL показывать и обрабатывать значение так же, как явный `null`. Отображение: пустая ячейка в листе, «—» в сводах. Правила подсветки ошибок валидации не срабатывают. Поиск и фильтр по колонке не находят такую ячейку по непустому запросу. При входе в редактирование поле ввода пустое. Правка, которую фронт отправляет по WebSocket, SHALL всегда содержать ключ `value`; пустое значение передаётся как `null`, а не опускается. + +#### Scenario: Выход из пустой ячейки без ввода +- **WHEN** пользователь входит в редактирование ячейки, ключа которой нет в строке, и выходит без ввода +- **THEN** сообщение `cell_updated` содержит `"value": null`, как и для ячейки с явным `null`, а пользователь не получает ошибку + +#### Scenario: Пустая ячейка свода 2 +- **WHEN** свод 2 загружен без ключа `data.q1.<поле>` у строки +- **THEN** ячейка показывает «—», как и для `null` + +#### Scenario: Валидация не срабатывает +- **WHEN** у строки листа отсутствует ключ поля, к которому привязано правило валидации (например, остаток бронирования) +- **THEN** ячейка не подсвечивается как ошибочная diff --git a/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md b/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md new file mode 100644 index 0000000..b88c77e --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md @@ -0,0 +1,127 @@ +# 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 «после» меньше, чем «до»