Andrey Epifancev 9d68cd5e1a docs(openspec): aurora-1843 — поведение свода 2 без изменений, решения 28.10
Поиск, колоночные фильтры, варианты фильтров и «Найдено N из M» — с точной
семантикой экрана (matchesSearch, MRT equals, faceting TanStack); регистр
без зависимости от локали БД; пороги — цели, а не условие слияния;
решения контроллера (три фазы/ветки, prefetch страницы 1, окружение тестов).

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

19 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, 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 длиннее 1000 символов SHALL отклоняться с 422.

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 из одних пробельных символов
  • 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 сужать выборку. Параметры длиннее 1000 символов SHALL отклоняться с 422. Все условия, включая 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