## Purpose Контракт API свода 2 (`/api/v1/svod/2/...`): постраничная выдача строк листа GO или RF с общим числом строк, серверные поиск и фильтры, компактное представление строки для экрана, варианты колоночных фильтров и совместимость прежнего ответа для выгрузки, тестов и стенда замеров. ## ADDED Requirements ### Requirement: Прежний ответ без новых параметров Запрос `GET /api/v1/svod/2/{sheet}/{year}`, в котором нет ни одного из параметров `view`, `search`, `smeta_type`, `smeta_direction`, 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}` без параметров до и после изменения - **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` (это «новый режим»), ответ SHALL содержать поле `total`. `total` — число строк, удовлетворяющих всем условиям запроса (год, организации, поиск, фильтры), без учёта `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 отбирать строки, в которых строка поиска (без пробелов по краям) входит как подстрока, без учёта регистра (и для кириллицы), хотя бы в одно значение полей строки: - все поля `data.header`; - все поля `data.contract_summary`; - все поля `data.contract_detail`. Числа сравниваются по их текстовой записи, отсутствующие значения не совпадают ни с чем. Пустой после обрезки `search` SHALL NOT сужать выборку. `search` длиннее 200 символов SHALL отклоняться с 422. Поиск SHALL сочетаться с остальными условиями по «И». #### Scenario: Поиск без учёта регистра по кириллице - **WHEN** в данных есть строка с `header.name = «Аренда помещения»` и выполняется запрос с `search=аРЕНДА` - **THEN** строка есть в выдаче, а `total` равен числу строк, у которых хотя бы одно поле из перечисленных групп содержит «аренда» без учёта регистра #### Scenario: Поиск по договору - **WHEN** у строки `contract_detail.counterparty = «ООО Ромашка»` и `search=ромаш` - **THEN** строка есть в выдаче #### Scenario: Пустой поиск не сужает - **WHEN** передан `search=%20%20` (два пробела) в новом режиме - **THEN** `total` равен `total` того же запроса без `search` #### Scenario: Слишком длинный поиск - **WHEN** длина `search` больше 200 символов - **THEN** ответ — 422 ### Requirement: Фильтры по виду сметы, направлению и организации `smeta_type` SHALL принимать одно из значений `АХР`, `КВ`, `Операц` и оставлять строки с точно таким `data.header.smeta_type`. `smeta_direction` SHALL принимать `Поддержка` или `Развитие` и оставлять строки с точно таким `data.header.smeta_direction`. Другие значения SHALL отклоняться с 422. Фильтр по организации задаётся параметром `org_ids` (список id) и SHALL пересекаться с организациями, разрешёнными пользователю, как и раньше. Все условия сочетаются по «И». #### Scenario: Фильтр по виду сметы - **WHEN** запрос с `smeta_type=КВ` - **THEN** у всех строк выдачи `data.header.smeta_type = «КВ»`, а `total` равен числу таких строк в полном ответе с теми же организациями #### Scenario: Комбинация условий - **WHEN** запрос с `search=X`, `smeta_direction=Развитие` и `org_ids=` - **THEN** выдача — пересечение трёх условий, `total` равен числу строк полного ответа для `org_ids=`, которые удовлетворяют поиску и направлению #### Scenario: Недопустимое значение фильтра - **WHEN** передан `smeta_type=AHR` или `smeta_direction=Support` - **THEN** ответ — 422 ### 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 больше номера квартала, `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: Варианты колоночных фильтров `GET /api/v1/svod/2/{sheet}/{year}/facets` с необязательным `org_ids` SHALL возвращать для строк листа за год по этим организациям (без учёта поиска и колоночных фильтров): - `total` — число строк; - `smeta_type` и `smeta_direction` — отсортированные списки различных непустых значений; - `org` — список различных организаций строк как пар `{id, name}`, отсортированный по `name`. Результат SHALL совпадать с тем, что можно вычислить по полному ответу того же листа с теми же `org_ids`. Неизвестный `sheet` SHALL давать 404. #### Scenario: Варианты совпадают с полным ответом - **WHEN** запрошены `/facets` и полный ответ GO для одного года и `org_ids` - **THEN** `total` равен длине полного ответа, множества `smeta_type`, `smeta_direction` и `{source_org_unit_id, org_name}` совпадают с вычисленными по полному ответу #### Scenario: Неизвестный лист - **WHEN** запрошен `/api/v1/svod/2/XX/{year}/facets` - **THEN** ответ — 404 ### Requirement: Права доступа на страницы и варианты Для не-администратора выдача страниц, `total` и варианты фильтров SHALL строиться только по организациям, привязанным к пользователю. Запрошенные `org_ids` вне этого множества SHALL отбрасываться, как в прежнем режиме. Если пересечение пусто, ответ SHALL быть пустым: `result` пустой, `total` = 0, списки вариантов пусты. #### Scenario: Чужая организация в фильтре - **WHEN** исполнитель, привязанный к организации A, запрашивает страницу и `/facets` с `org_ids=` - **THEN** `result` пустой, `total` = 0, `org` в вариантах пуст ### Requirement: Размер и стоимость ответа На данных стенда масштаба 1: - распакованный размер страницы `view=compact` SHALL быть не больше 40 % размера той же страницы `view=full`; - распакованный размер первой страницы (`limit` = размер страницы экрана), который экран получает до показа первых строк, SHALL быть не больше 5 % прежнего полного ответа того же листа. Время ответа запроса в прежнем режиме (без новых параметров) SHALL ухудшиться не больше чем на 10 % по медиане. #### Scenario: Замер размеров на стенде - **WHEN** `perf/analysis/api_split.py --scale 1` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets` - **THEN** отношение `bytes(compact) / bytes(full)` той же страницы не больше 0,40, `bytes(compact первой страницы) + bytes(facets)` не больше 5 % `bytes` полного ответа, медиана полного времени прежнего запроса не больше 1,10 от замера «до»