Andrey Epifancev f9fbd7399f openspec: архив aurora-1843-svod2-server-paging (PR #241/#243/#245)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:40:36 +04:00

163 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# summary2-api Specification
## Purpose
Контракт API свода 2 (`/api/v1/svod/2/...`): постраничная выдача строк листа GO или RF с общим числом строк, серверные поиск и колоночные фильтры с той же семантикой, что у экрана до изменения, варианты колоночных фильтров, компактное представление строки и совместимость прежнего ответа для выгрузки, тестов и стенда замеров.
## Requirements
### Requirement: Прежний ответ без новых параметров
Запрос `GET /api/v1/svod/2/{sheet}/{year}`, в котором нет ни одного из параметров `view`, `search`, `smeta_type`, `smeta_direction`, `org_name`, SHALL возвращать ответ, байт-в-байт совпадающий с ответом до изменения на тех же данных и с теми же `org_ids`, `offset`, `limit`, `sort_by`, `sort_direction`. Совпадать должны набор и порядок строк, `sort_order`, содержимое `data` со всеми ключами, включая `null`, и верхний уровень `success`/`message`/`result`/`count`. Поля `total` в таком ответе SHALL NOT быть. Excel-выгрузка свода 2 SHALL выдавать то же содержимое, что до изменения. Это условие слияния.
#### Scenario: Полный ответ «все организации» не изменился
- **WHEN** на одних и тех же данных стенда выполняются `GET /api/v1/svod/2/GO/{year}` и `GET /api/v1/svod/2/RF/{year}` без параметров до и после изменения (роли admin и dfip, все организации и одна организация, `offset=0&limit=50`)
- **THEN** тела ответов после распаковки совпадают побайтно (одинаковый sha256)
#### Scenario: Прежняя пагинация без total
- **WHEN** выполняется `GET /api/v1/svod/2/GO/{year}?offset=0&limit=2`
- **THEN** ответ содержит `count` не больше 2, `result` не длиннее 2 строк и не содержит ключа `total`
#### Scenario: Невалидные прежние параметры
- **WHEN** передан `offset=-1`, `limit=0`, `sort_by=bad` или `sort_direction=bad`
- **THEN** ответ — 422, как до изменения
### Requirement: Постраничная выдача с общим числом строк
Если в запросе есть хотя бы один из параметров `view`, `search`, `smeta_type`, `smeta_direction`, `org_name` (это «новый режим»), ответ SHALL содержать поле `total`. `total` — число строк, удовлетворяющих всем условиям запроса (год, `org_ids`, поиск, колоночные фильтры), без учёта `offset` и `limit`. `count` SHALL по-прежнему равняться длине `result`. Строки SHALL идти в том же порядке, что в полном ответе с теми же условиями: `sort_order` — номер строки в этом порядке, начиная с 1. Страницы с последовательными `offset` при `limit = L` SHALL в объединении давать ровно полный список без пропусков и повторов, если данные между запросами не менялись. Если `offset` не меньше `total`, `result` SHALL быть пустым, а `total` — верным. Это условие слияния.
#### Scenario: Склейка страниц равна полному списку
- **WHEN** при неизменных данных запрашиваются страницы `offset = 0, L, 2L, …` с `limit = L` и `view=full`, пока страница не станет короче `L`
- **THEN** последовательность `data.line_id` по всем страницам совпадает с последовательностью в ответе без `offset`/`limit` с теми же условиями, а `total` на каждой странице равен длине этого полного списка
#### Scenario: Страница за концом списка
- **WHEN** в новом режиме запрашивается `offset = total`
- **THEN** `result` пустой, `count` = 0, `total` равен числу строк по условиям запроса
#### Scenario: Явный view=full включает total
- **WHEN** выполняется `GET /api/v1/svod/2/RF/{year}?view=full&offset=0&limit=50`
- **THEN** строки в `data` совпадают с прежним полным представлением тех же строк, а в ответе есть `total`
### Requirement: Серверный поиск с семантикой экрана
Параметр `search` SHALL отбирать ровно те строки, которые до изменения оставлял поиск экрана:
1. Запрос нормализуется как `search.trim().toLowerCase()` в JavaScript (обрезка пробельных символов по правилам ECMAScript `String.prototype.trim`). Если после нормализации строка пустая, выборка SHALL NOT сужаться.
2. Строка проходит отбор, если нормализованный запрос входит подстрокой хотя бы в одно значение объекта, полученного слиянием `{...data.header, ...data.contract_summary, ...data.contract_detail}` (поздние группы перекрывают одноимённые ключи ранних). Поля `contract_summary`, одноимённые полям `contract_detail` (`counterparty`, `reference`, `deadline`, `comment`), поэтому SHALL NOT участвовать в поиске.
3. Каждое значение приводится к тексту как `String(value ?? '').toLowerCase()` в JavaScript:
- `null` — пустая строка;
- строка — как есть;
- число — по правилам `Number.prototype.toString` для двойной точности, которую экран получил из JSON ответа (например, `1234.50` → `1234.5`, `100.00` → `100`, `1e-7` → `1e-7`).
4. Значения берутся такими, какими они приходят в ответе этого листа. Например, на листе RF `contract_detail.expenses_q1..4` — это значения ответа (`COALESCE` договора и ЦКК).
5. Приведение к нижнему регистру SHALL NOT зависеть от локали базы данных. Для латиницы ASCII и кириллицы (включая Ё и буквы U+0400–U+042F, U+0490) результат SHALL совпадать с `toLowerCase()`.
Поиск сочетается с остальными условиями по «И». Длина `search` API не ограничивает: любой запрос даёт тот же результат, что поиск экрана (совпадения есть или их нет), без ошибки.
#### Scenario: Поиск без учёта регистра по кириллице независимо от локали
- **WHEN** в данных есть строка с `header.name = «Аренда помещения»`, база работает с `LC_CTYPE=C` или `en_US.utf8`, и выполняется запрос с `search=аРЕНДА`
- **THEN** в обоих случаях строка есть в выдаче, а `total` равен числу строк, отобранных поиском экрана до изменения
#### Scenario: Перекрытые поля contract_summary не ищутся
- **WHEN** у строки `contract_summary.counterparty = «ООО Ромашка»`, а `contract_detail.counterparty` пустой, и ни одно другое поле не содержит «ромаш»
- **THEN** при `search=ромаш` строки нет в выдаче, как и на экране до изменения
#### Scenario: Поиск по договору
- **WHEN** у строки `contract_detail.counterparty = «ООО Ромашка»` и `search=ромаш`
- **THEN** строка есть в выдаче
#### Scenario: Числа ищутся в записи JavaScript
- **WHEN** у строки `contract_detail.ceiling_amount = 1234.50` в БД
- **THEN** `search=1234.5` находит строку, а `search=1234.50` не находит
#### Scenario: Очень длинный поиск без ошибки
- **WHEN** передан `search` длиной 5000 символов, которого нет ни в одном значении
- **THEN** ответ — 200 с пустым `result` и `total` = 0, как пустой результат поиска на экране
#### Scenario: Пустой поиск не сужает
- **WHEN** в новом режиме передан `search` из одних пробельных символов
- **THEN** `total` равен `total` того же запроса без `search`
#### Scenario: Сверка с поиском экрана на данных стенда
- **WHEN** для GO и RF масштаба 1 по набору запросов (`аренда`, `АРЕНДА`, `1`, `,`, подстрока названия организации, подстрока контрагента, дробное число из данных) серверная выдача сравнивается с функцией `matchesSearch` экрана до изменения, применённой в Node.js к прежнему полному ответу
- **THEN** множества и порядок `line_id` совпадают для каждого запроса
### Requirement: Колоночные фильтры с семантикой экрана
Параметры `smeta_type`, `smeta_direction` и `org_name` SHALL оставлять строки, у которых соответствующее значение (`data.header.smeta_type`, `data.header.smeta_direction`, `data.header.org_name`) не `null` и после `toLowerCase().trim()` равно значению параметра после `toLowerCase().trim()`. Это семантика фильтра `equals` таблицы экрана до изменения. Пустое значение параметра SHALL NOT сужать выборку. Длина значений API не ограничивает, ошибки из-за длины нет. Все условия, включая `org_ids` и `search`, сочетаются по «И». `org_ids`, как и раньше, пересекается с организациями, разрешёнными пользователю.
#### Scenario: Фильтр по виду сметы
- **WHEN** запрос с `smeta_type=КВ`
- **THEN** у всех строк выдачи `data.header.smeta_type = «КВ»`, а `total` равен числу таких строк в полном ответе с теми же организациями
#### Scenario: Организации с одинаковым названием
- **WHEN** у двух организаций одинаковое название и запрос передаёт `org_name` с этим названием
- **THEN** в выдаче строки обеих организаций, как при выборе этого значения в фильтре «ССП» на экране до изменения
#### Scenario: Комбинация условий
- **WHEN** запрос с `search=X`, `smeta_direction=Развитие` и `org_name=<название>`
- **THEN** выдача — пересечение трёх условий, её состав совпадает с тем, что экран до изменения показывал при тех же поиске и фильтрах
### Requirement: Варианты колоночных фильтров с семантикой экрана
`GET /api/v1/svod/2/{sheet}/{year}/facets` принимает `org_ids`, `search`, `smeta_type`, `smeta_direction`, `org_name` с тем же смыслом, что у страниц. Ответ SHALL содержать:
- `total` — число строк по году и `org_ids` без поиска и фильтров (M в «Найдено N из M»);
- `search_total` — число строк по году, `org_ids` и поиску без колоночных фильтров (N в «Найдено N из M»);
- для каждой из колонок `smeta_type`, `smeta_direction`, `org_name` — пары «значение → число строк». Они считаются по строкам, отобранным годом, `org_ids`, поиском и фильтрами **двух других** колонок; фильтр самой колонки не применяется. Значения различаются точным совпадением строк, `null` не выдаётся.
Это семантика вариантов (`getFacetedUniqueValues`) таблицы экрана до изменения, где поиск применялся к данным до таблицы. Неизвестный `sheet` SHALL давать 404. Это условие слияния.
#### Scenario: Варианты с учётом поиска и других фильтров
- **WHEN** задан поиск `X` и фильтр `smeta_type=КВ`
- **THEN** варианты `smeta_direction` и `org_name` с числами строк посчитаны по строкам, найденным по `X` с видом сметы «КВ», а варианты `smeta_type` — по строкам, найденным по `X`, без фильтра вида сметы
#### Scenario: Варианты совпадают с вычисленными по полному ответу
- **WHEN** для набора сочетаний поиска и фильтров запрошены `/facets` и прежний полный ответ того же листа, года и `org_ids`
- **THEN** `total`, `search_total` и пары «значение → число» совпадают с вычисленными по полному ответу по правилам выше
#### Scenario: Неизвестный лист
- **WHEN** запрошен `/api/v1/svod/2/XX/{year}/facets`
- **THEN** ответ — 404
### Requirement: Компактное представление строки
При `view=compact` поле `data` каждой строки SHALL содержать только поля ниже, со значениями, равными значениям тех же путей в полном представлении:
| группа | поля |
|---|---|
| верхний уровень | `line_id` |
| `header` | `source_form`, `source_id`, `smeta_type`, `smeta_direction`, `org_name`, `name`, `item_id`, `num_group`, `justification` |
| `plan` | `plan_q1..4` |
| `allocation` | `internal_order`, `property_object` |
| `sequestration.DFIP` | `adj_q1..4` |
| `reserve` | `amount_q1..4` |
| `approved` | `approved_q1..4` |
| `contract_detail` | `counterparty`, `reference`, `subject`, `currency`, `ceiling_amount`, `expenses_q1..4`, `vat_rate`, `deadline` |
| `q1`–`q4` | `adj_current`, `adj_ssp`, `adj_rf`, `adj_reserve`, `payment_date`, `payment_amount`, `payment_amount_ho`, `payment_amount_rf`, `payment_act`, `booking_amount`, `actual_m1..3` |
| только `q4` | `actual_spod` |
| `q1`–`q3` | `transfer_to_q<k>` для всех k больше номера квартала, `transfer_to_economy` |
| `q2`–`q4` | `plan_revision_eco_change`, `plan_revision_item_adj`, `plan_revision_increase`, `plan_revision_sequester` |
Ключи со значением `null` SHALL отсутствовать. Нулевые числа SHALL сохраняться. Обёртка строки (`row_type`, `depth`, `sort_order`) SHALL совпадать с полным представлением.
#### Scenario: Компактная строка — проекция полной
- **WHEN** одна и та же страница запрошена с `view=full` и с `view=compact`
- **THEN** строки идут в одном порядке, и для каждой строки компактное `data` равно проекции полного `data` на перечисленные пути без ключей со значением `null`
#### Scenario: Ноль не теряется
- **WHEN** в полном представлении `plan.plan_q1 = 0`
- **THEN** в компактном `plan.plan_q1 = 0`
### Requirement: Права доступа на страницы и варианты
Для не-администратора выдача страниц, `total` и варианты фильтров SHALL строиться только по организациям, привязанным к пользователю. Запрошенные `org_ids` вне этого множества SHALL отбрасываться, как в прежнем режиме. Если пересечение пусто, ответ SHALL быть пустым: `result` пустой, `total` = 0, `search_total` = 0, списки вариантов пусты.
#### Scenario: Чужая организация в выборке
- **WHEN** исполнитель, привязанный к организации A, запрашивает страницу и `/facets` с `org_ids=<B>`
- **THEN** `result` пустой, `total` = 0, варианты пусты
### Requirement: Замер размера и стоимости ответа
Для GO и RF на масштабах 1 и 2 SHALL быть сняты и приведены в PR цифры «до/после». Цифры условием слияния не являются: недобор оформляется follow-up задачей. Что снимается:
- распакованный размер страницы `view=compact` и той же страницы `view=full`;
- распакованный размер первой страницы и вариантов против прежнего полного ответа;
- медиана времени прежнего полного запроса.
Целевые значения:
- `compact` не больше 40 % `full`;
- первая страница вместе с вариантами не больше 5 % прежнего полного ответа;
- время прежнего запроса не больше 1,10 значения «до».
#### Scenario: Замер размеров на стенде
- **WHEN** `perf/analysis/api_split.py --scale <N>` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets`
- **THEN** в отчёте PR есть отношения размеров и времён к целевым значениям, а каждый недобор записан как follow-up