## 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`, `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 больше номера квартала, `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=` - **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 ` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets` - **THEN** в отчёте PR есть отношения размеров и времён к целевым значениям, а каждый недобор записан как follow-up