Andrey Epifancev 6e607b1647 docs(openspec): aurora-1843-svod2-server-paging — proposal, spec, design
Свод 2: серверная постраничная загрузка (поиск, три фильтра, total,
варианты фильтров) и компактный ответ; фронт догружает строки окном
страниц; замеры памяти, времени до первых строк, байтов и шага скролла.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:27:17 +04:00

14 KiB
Raw Blame History

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=<id>
  • THEN выдача — пересечение трёх условий, total равен числу строк полного ответа для org_ids=<id>, которые удовлетворяют поиску и направлению

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> для всех 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=<B>
  • 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 от замера «до»