163 lines
19 KiB
Markdown
163 lines
19 KiB
Markdown
# 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
|