From 6e607b16474359fb4b9947940ce84f9f034e9da2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:27:17 +0400 Subject: [PATCH 01/17] =?UTF-8?q?docs(openspec):=20aurora-1843-svod2-serve?= =?UTF-8?q?r-paging=20=E2=80=94=20proposal,=20spec,=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Свод 2: серверная постраничная загрузка (поиск, три фильтра, total, варианты фильтров) и компактный ответ; фронт догружает строки окном страниц; замеры памяти, времени до первых строк, байтов и шага скролла. Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 2 + .../aurora-1843-svod2-server-paging/design.md | 509 ++++++++++++++++++ .../proposal.md | 102 ++++ .../specs/summary2-api/spec.md | 136 +++++ .../specs/summary2-screen/spec.md | 90 ++++ 5 files changed, 839 insertions(+) create mode 100644 openspec/changes/aurora-1843-svod2-server-paging/.openspec.yaml create mode 100644 openspec/changes/aurora-1843-svod2-server-paging/design.md create mode 100644 openspec/changes/aurora-1843-svod2-server-paging/proposal.md create mode 100644 openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md create mode 100644 openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md diff --git a/openspec/changes/aurora-1843-svod2-server-paging/.openspec.yaml b/openspec/changes/aurora-1843-svod2-server-paging/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/aurora-1843-svod2-server-paging/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md new file mode 100644 index 0000000..c7731bf --- /dev/null +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -0,0 +1,509 @@ +# Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ + +Мотивация и цифры — в `proposal.md`, требования — в `specs/summary2-api/spec.md` и `specs/summary2-screen/spec.md`. Ссылки `file:line` ниже даны по вершине ветки `feat/236-aurora-1843-svod2-server-paging`: это `399234a`, вершина AURORA-1842. + +## Контекст: как устроено сейчас + +**API.** `GET /api/v1/svod/2/{sheet}/{year}`: +- обработчик — `api/src/api/v1/summary.py:93-146`. Он уже принимает `org_ids`, `offset`, `limit` (`:99-100`), `sort_by` и `sort_direction`; +- `count = len(rows)` (`:143-146`); +- ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`), а `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`. + +Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`): +- через `_allowed_org_ids` (`:73-88`) пересекает запрошенные организации с разрешёнными; +- для администратора `None` означает «все»; +- для остальных пустое пересечение даёт `[]`. + +Репозиторий — `api/src/repository/summary_repository.py:34-77`, это `SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов)`. + +**SQL.** Актуальные версии `v3.v_svod2_go` и `v3.v_svod2_rf` лежат в `api/alembic/versions/sql/0049_summary2_related_records.sql:6-303` и `:305-595`. Каждая собрана из CTE: +1. `eligible_lines` — `DISTINCT` по 13 LEFT JOIN «наличия данных». GO берёт FORM_1 и FORM_4 (`:71`), RF — FORM_2 (`:369`). Значения вычисляются тут же: `smeta_type` из `ei.sheet`, `org_name = ou.title`; +2. `numbered_lines` — `row_number()` по `org_name`/`item_id` или `section_code`, затем `form_type_code`, `form_id`, `line_id` (`:151-168`); +3. `page AS MATERIALIZED` — `LIMIT p_limit OFFSET p_offset` (`:169-173`); +4. сборка большого `jsonb` только для строк страницы (`:179-302`). + +Значит, стоимость страницы — это полный отбор плюс сборка `jsonb` только для этой страницы. `v3.v_svod2_go_export` (`:597-902`) — отдельная компактная функция для Excel GO, её не трогаем. Прежние перегрузки этих функций уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре: `0022_summary2_contract_rows.sql:11-13`, `:200-202`. + +**Кто зовёт `GET /svod/2/...` и `get_svod2_rows`:** +- фронт — только `Svod2Page` через `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`, без `offset`/`limit`); +- Excel RF — `export_service.py:236-245`, `offset=None`, `limit=None`; +- интеграционные тесты — `api/tests/integration/test_summary_api_smoke.py:44-140`; +- стенд: `perfkit.gen.verify` (`perf/perfkit/gen/verify.py:146-148`), `perfkit.bench` (повторяет URL из сетевого лога), `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`. + +**Фронт.** `web/src/pages/Svod2Page/Svod2Page.jsx`: +- состояние вкладок `tableStateBySheet` держит `rows` целиком (`:21-22`, `:302`, `:345-348`); +- `loadSummary` (`:335-367`) берёт ответ из кэша `SvodContext` (`web/src/pages/SvodPage/SvodContext.jsx:46-70`). Ключ кэша — лист + год + организации, кэш очищается при размонтировании провайдера маршрута `/svod/2` (`web/src/app/Routes.jsx:54-59`). `SvodContext` и `SvodFilters` общие со сводом 1; +- поиск — `matchesSearch` (`:283-294`) по `{...header, ...contract_summary, ...contract_detail}`, фильтрация в `filteredRows` (`:387-391`), debounce ввода 300 мс в `SvodFilters.jsx:68-77`; +- три колоночных select-фильтра — `smeta_type`, `smeta_direction`, `org_name` (`:158-176`) — используют `enableFacetedValues` (`:400`). Сортировка выключена (`:411`); +- «Найдено N из M» (`:502-506`) считает только поиск, колоночные фильтры не учитывает; +- изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`); +- данные без «Загрузить» не грузятся (`:448-455`). «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`), сервер подставляет все разрешённые; +- виртуализация строк — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: `ROW_HEIGHT = 37`, `measureElement` по `offsetHeight`). Виртуализация колонок из 1842 — `:33-34`, `:407-408`; +- кнопки Excel-выгрузки свода 2 на фронте нет: по `web/src` нет ни `export/summary`, ни `summary/2`. + +**Стенд.** `perf/e2e/scenarios/scroll.spec.ts`: +- для свода 2 кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`); +- ждёт `MIN_ROWS` строк `tbody tr` (`waitRows`); +- `json_bytes_sheet` — максимум байтов одного ответа по `respRe` (`:176-184`); +- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`armStable`/`awaitStable`, `:113-150`, `:221-227`); +- времени до первых строк сценарий не пишет; +- сценария памяти для сводов нет: `memory.spec.ts` их не открывает. + +`perf/analysis/explain_manual.py` функции свода 2 **не** использует (запросы — только FORM_2/AHR и FORM_4/CAP, `:59-81`), поэтому смена сигнатуры его не ломает. + +**Тестовые данные.** В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2: единственная строка FORM_1 относится к 2027 году и без данных. Смоук-тесты свода 2 фактически проверяют пустой ответ. + +## Цели / не цели + +**Цели:** +- память страницы свода 2 ограничена окном строк и не растёт с объёмом данных; +- первые строки появляются за время одной страницы, а не всего ответа; +- байты и Python-время на строку уменьшаются за счёт компактного вида; +- поиск и фильтры не обходят все строки в браузере; +- прежние потребители API не замечают изменения. + +**Не цели:** +- менять отбор строк свода 2 (это O-5 / AURORA-1917); +- адаптировать Excel-выгрузку под экранные фильтры; +- серверная сортировка по клику на заголовок (на экране её нет); +- отказ от pydantic на строку (это AURORA-1848); +- полнотекстовый или триграммный индекс поиска; +- менять свод 1 и общий `SvodFilters`. + +## Решения (согласовано 28.10) + +1. Вместо аналитики — реализация, варианты C + B черновика. Вариант A (только политика кэша на фронте) не нужен. +2. Сервер. У существующих `offset`/`limit` появляются серверный поиск (по тем же полям, что `matchesSearch` сейчас), три фильтра (`smeta_type`, `smeta_direction`, организация через `org_name`/`org_ids`) и поле `total`. `count` сохраняет прежний смысл (длина `result`). +3. Отдельный лёгкий запрос вариантов для трёх фильтров (facets). +4. Компактный ответ (только поля, которые экран читает или ищет) включается параметром, например `view=compact`. Без новых параметров ответ байт-в-байт прежний. +5. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет. Выгрузку под экранные фильтры не подгоняем. +6. Фронт: та же таблица, строки догружаются страницами при прокрутке. В памяти — окно плюс запас, а не весь ответ. Поиск и фильтры уходят на сервер (debounce 300 мс уже есть), «Найдено N из M» строится из `total`. Кэш `SvodContext` перестаёт копить полные ответы. +7. SQL: новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и сценарий `scroll.spec.ts` (клик «Все ССП/РФ» → «Загрузить») обновляются вместе с UX, чтобы замер оставался сопоставимым. +8. Три PR в стеке: сервер (SQL + API + тесты), фронт (догрузка, поиск, фильтры), замер и сценарии perf. +9. AURORA-1917 (O-5) — отдельная задача. Её нельзя вливать между замерами «до» и «после» 1843. +10. Метрики успеха: память страницы после GO «все» → RF «все» → GO (нужен новый сценарий `svod2-memory.spec.ts`), время до первых строк, байты ответа, шаг прокрутки свода 2 не хуже результата 1842. Масштабы 1 и 2. + +## Решения по устройству + +### 1. Контракт `GET /api/v1/svod/2/{sheet}/{year}` + +- **Прежние параметры** без изменений: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`. +- **Новые параметры, все необязательные:** + + | параметр | тип | ограничения | + |---|---|---| + | `view` | `Literal["full","compact"] \| None` | | + | `search` | `str \| None` | `max_length=200`, обрезается по краям | + | `smeta_type` | `Literal["АХР","КВ","Операц"] \| None` | | + | `smeta_direction` | `Literal["Поддержка","Развитие"] \| None` | | + +- **Новый режим** включается, когда хотя бы один новый параметр не `None`, в том числе явный `view=full`. В новом режиме ответ — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). В прежнем режиме ответ тот же `BaseListResponse[SheetResponse]`, что и раньше. + - Аннотация возврата сейчас задаёт `response_model`, и модель отрезала бы `total`. Поэтому у обработчика нужно явно задать `response_model=Union[...]` или `response_model=None` и вернуть нужную модель по режиму. + - Обязателен тест «в прежнем режиме нет `total`». +- **Значения фильтров** — те же строки, что видит экран в `data.header` (`АХР`, `КВ`, `Операц`; `Поддержка`, `Развитие`), а не коды БД. Так варианты из facets подставляются в запрос без перевода. +- **Фильтр по организации** не получает нового параметра: экран сужает `org_ids` до id, выбранного в колонке «ССП», а сервер, как и раньше, пересекает его с разрешёнными (`_allowed_org_ids`). + - Альтернатива — `org_name` (точное совпадение по названию, как сейчас на клиенте) — отвергнута. Названия не уникальны по схеме (`org_unit.title` без `unique`), а фильтр по `bf.org_unit_id` уже есть в SQL. +- **`total`** — `count(*) OVER ()` в той же выборке, что нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, чтобы узнать `total`: это редкий путь, второго SQL на каждую страницу нет. + +### 2. Эндпоинт вариантов фильтров + +`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=...` возвращает: + +```json +{ + "success": true, + "message": null, + "result": { + "total": 5563, + "smeta_type": ["АХР", "КВ", "Операц"], + "smeta_direction": ["Поддержка", "Развитие"], + "org": [{"id": 12, "name": "…"}] + } +} +``` + +- Обёртка — `BaseSingleResponse[Svod2Facets]`. +- Варианты считаются по базовому множеству (год + организации) **без** поиска и колоночных фильтров, `total` этого множества — это M в «Найдено N из M». +- Права — тот же `_allowed_org_ids`. Если пересечение пусто — нули и пустые списки. +- `sheet` проходит ту же валидацию, что и в основном эндпоинте (404 на неизвестный). + +### 3. SQL: новая миграция + +**Файлы.** `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова. Номер — в разделе «Совместимость и порядок PR». + +**Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется (добавляется столбец), поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку. + +**Общий отбор `v3.svod2_lines(...)`.** Новая функция: +- сигнатура `(p_year int, p_form_types varchar[], p_org_unit_ids int[], p_search text, p_smeta_type varchar, p_smeta_direction varchar)`; +- возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`; +- тело — `eligible_lines` из 0049 **без изменения условий отбора**, параметризованное списком типов форм (`{FORM_1,FORM_4}` для GO, `{FORM_2}` для RF), плюс три новых предиката. Они пишутся как `(p_x IS NULL OR …)`; +- `smeta_direction` — тот же `CASE bl.direction`, что в `header` (0049:197-201). + +Функцию писать `LANGUAGE sql STABLE` **без** `SET search_path` (имена полностью квалифицированы `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивание не произошло (в EXPLAIN есть `Function Scan on svod2_lines`), это допустимо: множество строк ≤ десятков тысяч узких записей. Решение — `#3` в разделе решений без обсуждения. + +**Поиск.** Предикат: + +```sql +strpos(lower(concat_ws(E'\x1f', <поля>)), lower(p_search)) > 0 +``` + +- Поля: все ключи `header` (`section_code`, `item_id`, `num_group`, `name` = `COALESCE(bl.name, ei.name)`, `expense_item_id`, `justification`, `internal_order`, `vsp_id`, `project_id`, `direction`, `smeta_direction`, `source_form`, `source_id`, `source_org_unit_id`, `org_name`, `smeta_type`), все столбцы `contract_summary` и все столбцы `contract_detail`, кроме `line_id`. Таблицы уже присоединены в отборе как `presence_contract_summary` и `presence_contract_detail` (связь 1:1 по PK `line_id`, `DISTINCT` не размножается). +- Разделитель `\x1f` не даёт совпасть «через границу» двух значений; `strpos` не требует экранировать `%` и `_`. +- Числа приводятся `::text` (`numeric` → как в jsonb), даты — `::text` (ISO, как в JSON). +- Регистр кириллицы: `lower()` зависит от `LC_CTYPE` базы. На стенде (`en_US.utf8`) работает. Интеграционный тест с «аРЕНДА» доказывает это на конкретной базе, риск — в разделе рисков. + +**Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.** +- Аргументы — прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type varchar DEFAULT NULL`, `p_smeta_direction varchar DEFAULT NULL`, `p_compact boolean DEFAULT false`. +- Результат — прежние 8 столбцов + `total_count bigint`. Порядок прежних столбцов не меняется: репозиторий выбирает их по имени. +- `numbered_lines` строится поверх `v3.svod2_lines(...)` с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`. +- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF (например, `approved` у RF учитывает `ss.id`). +- `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END`. +- Вызов прежними 6 аргументами (Excel RF, прежний режим API) работает за счёт значений по умолчанию и даёт те же строки, `sort_order` и `data`. + +**Проекция `v3.svod2_compact(data jsonb) RETURNS jsonb`** (`LANGUAGE sql IMMUTABLE`): `jsonb_strip_nulls(jsonb_build_object(...))` по путям из требования «Компактное представление строки». Это те же пути, что `export_keys` в `summary_repository.py:85-140`, плюс `line_id`, `header.source_form`, `header.source_id`, которые нужны для `getRowId` (`Svod2Page.jsx:397`). +- Альтернатива — массив значений без ключей, как `v_svod2_go_export.row_values`: меньше байтов, но фронту пришлось бы перекладывать строки в форму под `valueAt`, а проверка «компакт = проекция полного» стала бы сложнее. Отвергнута (`#4`). +- Альтернатива — отдельная сборка компактного `jsonb` прямо из таблиц: быстрее на страницу, но второй экземпляр сложной логики `booking_amount`/`plan_revision_*` (0049:248-284) с риском расхождения. Отвергнута: страница — это сотни строк, сборка полного `jsonb` для неё дешёвая. + +**Варианты фильтров `v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[])`.** Возвращает строки `(kind varchar, value varchar, org_unit_id int, n bigint)`: +- `kind ∈ {'total','smeta_type','smeta_direction','org'}`; +- значения считаются по `v3.svod2_lines(p_year, <типы форм по листу>, p_org_unit_ids, NULL, NULL, NULL)`; +- `NULL`-значения `smeta_type` и `smeta_direction` не выдаются (`#7`); +- форму ответа API собирает репозиторий. + +**PG15.** Не использовать возможности PG16+: `any_value`, конструкторы SQL/JSON (`JSON_OBJECT`, `JSON_ARRAY`…), `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` должны быть алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `concat_ws`, `strpos` есть в PG15. + +**`v3.v_svod2_go_export` не трогается.** У неё остаётся своя копия `eligible_lines`. + +### 4. Сервис и репозиторий + +- `SummaryRepository.get_svod2_rows` — без изменений сигнатуры и SQL: прежний режим и Excel RF. +- Новые методы: + - `SummaryRepository.get_svod2_page(...)` — новые аргументы и `total_count`, возвращает `(rows, total)`; + - `SummaryRepository.get_svod2_facets(...)`; + - в сервисе `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`. +- Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`. +- Заголовок `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах, в лог добавляются новые параметры. На этом заголовке держится `api_split.py`. + +### 5. Фронт: окно страниц в той же таблице + +**Константы.** `PAGE_SIZE = 300`, окно — видимая страница ±1 (не больше 3 страниц в данных таблицы), кэш страниц — LRU не больше 5 страниц на вкладку (`#5`). Высота строки берётся из того же источника, что `estimateSize` виртуализатора: +- сейчас 37 px; +- после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()` из её `createFixedRowVirtualizerOptions`. + +Окно требует фиксированной высоты строки. Пока 1836 не влита, `measureElement` должен возвращать константу. + +**Данные таблицы.** Это не весь список, а срез окна `[windowStart, windowEnd)`: +- загруженные строки окна плюс заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна; +- позиция среза задаётся отступами виртуализатора строк `rowVirtualizerOptions = { ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`; +- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают `paddingStart`/`paddingEnd` (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` всегда стоит на `i * rowH`, а полоса прокрутки соответствует `total`; +- объектов строк TanStack (`createRow` создаёт десятки замыканий на строку) не больше размера окна, а не `total`; +- объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`) **не править** — это hunk AURORA-1836. Новые опции собираются в компоненте разворотом этого объекта. + +**Прокрутка.** +- `muiTableContainerProps.onScroll`, сглаженный через `requestAnimationFrame`, считает `firstVisible = floor(scrollTop / rowH)` и видимую страницу; +- когда видимая страница меняется, пересчитываются окно и данные, а недостающие страницы окна запрашиваются (соседние страницы окна — это и есть упреждающая загрузка); +- если видимая область целиком в отступе (быстрый перенос ползунка), виртуализатор рисует строки края среза вне экрана, а пользователь видит пустое место, пока окно не сдвинется на следующем кадре и не придут страницы. Это ожидаемо. + +**Заглушки.** +- `muiTableBodyRowProps` для строки-заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`; +- правило в `SVOD2_TABLE_CONTAINER_SX` (`svod2Styles.js`) делает текст ячеек прозрачным и даёт ровный фон. Тогда `Cell`-функции колонок не меняются: на заглушке `valueAt` даёт `undefined` и `'—'`, но его не видно; +- `getRowId`: у заглушки `ph-${index}`, у данных — прежний ключ. + +**Запросы.** В `web/src/api/summary.js`: +- `SummaryApi.getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection }, { signal })` — всегда `view=compact`, пустые параметры не передаются; +- `SummaryApi.getSvod2Facets(sheet, year, orgIds, { signal })`; +- `SummaryApi.get` остаётся для свода 1. + +**Состояние вкладки** — хук загрузки страниц, например `useSvod2Pages.js` (без JSX). Держит: +- `queryKey` (лист, год, организации, поиск, три фильтра); +- `generation`; +- `total`; +- `facets`; +- `pages: Map` (LRU); +- `inflight: Map`; +- `scrollTop`. + +Чистая логика — окно, сборка среза с заглушками, LRU, параметры запроса из условий, «показывать ли Найдено» — выносится в `web/src/pages/Svod2Page/svod2Paging.js` и тестируется `node --test`. Ответ, пришедший с устаревшим `generation`, отбрасывается; при смене условий текущие запросы отменяются через `AbortController`. + +**Что вызывает запросы:** +- «Загрузить» или «Обновить»: новый `generation`, сброс страниц, прокрутка в начало, параллельно `facets` и страница 0; +- смена поиска (после debounce в `SvodFilters`) или колоночного фильтра при загруженных данных: новый `generation`, сброс страниц, прокрутка в начало, только страница 0. Варианты фильтров не перезапрашиваются: они не зависят от поиска и фильтров; +- смена года или организаций: как сейчас, подсказка «Нажмите «Загрузить»…» (`:448-455`); +- переключение вкладки: состояние другой вкладки (окно ≤ 5 страниц) сохраняется, возврат — без запроса. + +**Колоночные фильтры.** +- `manualFiltering: true`, `enableFacetedValues: false`; +- у трёх колонок `filterSelectOptions` из `facets`: строки для `smeta_type`/`smeta_direction`, `{ label: name, value: String(id) }` для `org_name`. Опции подмешиваются в `columns` в `useMemo` поверх `getColumnConfig(sheet)`, кэш конфигурации колонок по листу остаётся; +- значение фильтра «ССП» → `org_ids = [id]` вместо организаций из выбора «ССП/РФ» (они уже ограничивают варианты); +- сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется. + +**«Найдено N из M».** Показывается, если данные загружены, загрузка не идёт, `total !== facets.total`: `Найдено ${total} из ${facets.total} строк` в `ru-RU`. + +**Ошибки.** +- первая страница или `facets` — прежний `Alert` с «Повторить» (`:456-468`); +- следующие страницы — `toast.error`, страница остаётся незагруженной и запрашивается снова, когда опять попадёт в окно. + +**Если `total` изменился между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез и отступы пересчитываются. Возможный сдвиг строк между страницами лечит «Обновить» (риск ниже). + +**`SvodContext` не меняется.** Свод 2 просто перестаёт звать `getSummary`/`invalidateSummary`, поэтому полные ответы в кэше не копятся. Кэш остаётся только у свода 1. + +**Запасной вариант (`#6`).** Если `paddingStart`/`paddingEnd` в MRT 3.2.1 ведут себя некорректно (проверка — первая задача фронт-PR на живом GO «все»), допускается массив длины `total` с общей заглушкой на незагруженных местах. Память тогда растёт как O(`total`) объектов строк TanStack, критерий памяти проверяется замером. Переход на запасной вариант записывается в этот файл, в раздел решений без обсуждения. + +### 6. Стенд `perf/` (PR 3) + +**`perf/e2e/scenarios/scroll.spec.ts`, только `kind === 'svod2'`.** Код должен работать и со сборкой «до»: заглушек и новых запросов там нет, метрики просто вырождаются. +- `waitRows` считает строки `tbody tr:not([data-svod2-placeholder])`. +- Новая метрика `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными. +- Байты: + - `json_bytes_sheet` сохраняет смысл «максимум одного ответа» (у «после» это самая большая страница); + - новый `json_bytes_total` — сумма всех ответов по `respRe` за тест; + - новый `json_bytes_first` — сумма до появления первых строк. + + `respRe` свода 2 расширяется на `/facets`. +- Новая метрика `placeholder_frame_share` — доля кадров прокрутки, в которых в видимой области контейнера есть заглушка (сэмплер на `requestAnimationFrame` в странице или колбэк шага в `lib/scroll.ts`). Рядом пишется `pages_requested`. +- Тест фильтра: после `fill('1')` ждать ответ запроса с `search=` (до 5 с; если запроса нет — это сборка «до» с клиентским поиском), затем `awaitStable`. Новая метрика `filter_result_ms` — от ввода до стабилизации. +- Подписи «ССП/РФ», «Все ССП/РФ», «Загрузить» не меняются (UX сохранён). Клики `:194-200` остаются. + +**Новый `perf/e2e/scenarios/svod2-memory.spec.ts`.** +- Роль dfip, свежий контекст на повтор, `PERF_REPEATS` (по умолчанию 3). +- Шаги: + 1. `/svod/2`, GO «Все ССП/РФ» → «Загрузить» → строки → `heapUsed` (`perf/e2e/lib/cdp.ts:45`) → точка `go`; + 2. вкладка RF, «Все ССП/РФ» → «Загрузить» → строки → точка `rf`; + 3. по `PERF_SVOD2_MEM_SCROLL=1` (по умолчанию выключено, на масштабе 1 включить) — прокрутка RF до конца и обратно → точка `rf_scrolled`; + 4. вкладка GO → строки видны → точка `final`. +- Метрики: `heap_used_mb` по точкам, `nodes` (`domCounters`), `first_rows_ms` обеих загрузок. Запись `scenario: "svod2-memory"`, агрегирует `perfkit.aggregate`. + +**`perf/analysis/api_split.py`.** +- Прежние строки (полные GO/RF) остаются: это проверка «прежний режим не медленнее». +- Добавляются: + - GO/RF `?view=full&offset=0&limit=300`; + - GO/RF `?view=compact&offset=0&limit=300`; + - GO/RF `/facets`; + - RF `?view=compact&offset=0&limit=300&search=1`. + +**`perf/analysis/explain_manual.py`.** Новые записи: +- `svod2_go_page` — `SELECT … FROM v3.v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, true)`; +- `svod2_rf_page`; +- `svod2_rf_search` (`p_search='1'`); +- `svod2_go_facets`. + +Варианты — по умолчанию и `.rerun_default`. Они показывают, сколько стоит отбор строк в каждой странице. + +**`perf/analysis/scale_compare.py`** (`:43-44`): разделить ключи на «GET свод 2 {sheet}» (прежний полный), «… страница» (в query есть `limit`) и «… facets». + +**`perfkit.bench`.** После 1843 сетевой лог содержит десятки URL страниц, и бенч повторит каждый. На масштабе 1 это около 50 URL × 20 повторов × ~0,3 с ≈ 5 мин; на масштабе 2 — в ~4 раза больше. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`. + +**Новый `perf/analysis/svod2_paging_check.py`** — сверка на данных стенда, только чтение. Под dfip и admin для GO и RF: +- склейка всех компактных страниц совпадает по `line_id` и порядку с прежним полным ответом и равна его проекции; +- `total` равен длине полного; +- для поисков (`аренда`, `АРЕНДА`, `1`, подстрока названия организации) и для каждого значения трёх фильтров `total` и состав совпадают с фильтром по полному ответу на Python, повторяющим требование «Серверный поиск»; +- `/facets` совпадает с вычисленным по полному ответу. + +Запускается перед замером «после». + +## Совместимость и порядок PR + +**Стек.** `test` ← … ← AURORA-1835 (PR #217) ← AURORA-1842 (PR #227) ← 1843: +- PR 1 `feat/236-…` — сервер; +- PR 2 — фронт; +- PR 3 — perf. + +Каждый PR зелёный сам по себе: +- после PR 1 старый фронт работает (прежний режим не изменился); +- PR 2 использует только API из PR 1; +- PR 3 меняет только `perf/`. + +**Номер миграции.** В ветке голова — `0049`. Номер `0050` уже заняли AURORA-1831 (`0050_project_technical_number_lock`), AURORA-1844 (`0050_upd_form_cells_full_rows`) и AURORA-1846 (`0050_form4_sheet_sum`), между собой они перенумеруются при слиянии. 1843 берёт рабочий номер `0053_svod2_server_paging` (после 0050–0052 этих трёх). При каждом rebase PR 1 нужно: +- проверить `alembic heads` (должна быть одна голова); +- поставить номер «голова + 1»; +- переименовать `.py` и `.sql`; +- поправить `revision`, `down_revision` и имя файла в `run_sql_file_migration`; +- перенацелить `test_summary2_related_records_sql.py` на новое имя. + +Два head ломают `alembic upgrade head`, а при `DEBUG=false` — и старт приложения. + +**AURORA-1836 (PR #222, от `test`).** Правит `Svod2Page.jsx`: +- `:26-31`: `ROW_HEIGHT` → `2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3; +- `muiTableBodyRowProps.style.height`. + +1843 эти строки не трогает. Новые опции виртуализатора собираются в компоненте из `ROW_VIRTUALIZER_OPTIONS`, а у `muiTableBodyRowProps` 1843 добавляет класс и атрибут заглушки. Это функция-объединение: при rebase слить со `style.height` из 1836. + +Высота строки для окна должна идти из того же источника, что `estimateSize`. После rebase — из 1836, иначе отступы разойдутся с позициями строк: 37 против 44 px. + +Если 1836 вольётся в `test` раньше замера, базу «до» переснять на ребейзнутой вершине 1842. + +**AURORA-1848** (тяжёлые GET без pydantic, ещё не начата) переписывает тот же `get_summary2` на готовый JSON из SQL. Договорённость: +- кто вливается вторым, сохраняет оба режима 1843; +- для страниц (сотни строк) выигрыш 1848 мал; +- для прежнего режима (Excel RF не через HTTP, бенч, verify) 1848 вправе менять упаковку только с сохранением JSON по смыслу, как требует сама 1848. + +Требование «байт-в-байт» из 1843 относится к состоянию до 1848. + +**AURORA-1917 (O-5).** Исправление отбора RF по `is_ssp` уменьшит ответ RF и число строк. Его нельзя вливать между замером «до» и «после» 1843: либо до базового замера (тогда «до» переснимается), либо после «после». Если 1917 придёт раньше, правка отбора ляжет в `v3.svod2_lines` (одно место) и в `v_svod2_go_export`. + +**Выкатка и откат.** Миграция только заменяет функции и добавляет новые, данных не трогает. `downgrade()` в проекте пустые: откат PR 1 — новой миграцией, которая восстанавливает тела из 0049. Откат PR 2 безопасен сам по себе. + +## Тестирование (что доказывает корректность) + +**PR 1, `api/tests/unit`:** +- валидация параметров через `TestClient` без БД (по образцу `test_query_validation.py`): 422 на `search` > 200, на неизвестные `smeta_type`/`smeta_direction`, на прежние невалидные `offset`/`limit`/`sort_*`; +- сервис: `_allowed_org_ids` применяется к страницам и вариантам; пустое пересечение даёт `[]`/0 без вызова SQL; второй вызов за `total` при пустой странице и `offset > 0` (моки репозитория, по образцу `test_export_service_query_params.py`); +- выбор режима: без новых параметров зовётся `get_svod2_rows`, и в ответе нет `total`; +- `test_summary2_related_records_sql.py` перенацелить на новую миграцию: отбор из `v3.svod2_lines` для GO (`FORM_1`, `FORM_4`) и RF (`FORM_2`), те же проверки, что для 0049. Добавить: поиск находит по `contract_summary`, `contract_detail` и `header`; разделитель не даёт совпасть через границу значений; фильтры `smeta_type`/`smeta_direction`. + +**PR 1, `api/tests/integration`.** +- Фикстура (рядом с `client`) засевает в той же откатываемой сессии данные свода 2 за год, которого нет в `fixture.sql`: + - 3 организации (одна с не уникальным `title`); + - FORM_1, FORM_4 и FORM_2; + - все три `smeta_type`, оба направления и `direction = NULL`; + - `contract_summary` и `contract_detail` с кириллицей разного регистра; + - строки с `plan_q1 = 0`; + - пользователь-исполнитель с одной организацией. +- Оракул — прежний полный ответ: + - склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом; + - `search` (`аРЕНДА`, подстрока контрагента, цифра) и каждый фильтр совпадают с фильтром полного ответа на Python по требованию «Серверный поиск»; + - `view=compact` — проекция `view=full` без `null`, нули на месте; + - `/facets` совпадает с вычисленным по полному; + - исполнитель не видит чужую организацию ни в страницах, ни в вариантах; + - прежний режим — ключи как раньше и без `total`; существующие тесты `test_summary_api_smoke.py` проходят без правок. +- Весь набор `pytest` (зелёный только целиком): PG16 на стенде (отдельная БД `budget_test`, см. P4 «Стенд для тестов») плюс проверка контроллером на PG15.12. + +**PR 1, байт-в-байт на данных стенда.** До и после миграции снять `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, «все» и одна организация, `offset=0&limit=50`) и Excel RF. Сравнить sha256 распакованных тел; для xlsx — `openpyxl`-значения всех ячеек. + +**PR 2, `node --test web/src/pages/Svod2Page/*.test.js`:** +- окно по `scrollTop`/`rowH`/`total` (край начала, края конца, `total < PAGE_SIZE`, `total = 0`); +- сборка среза: число заглушек на последней неполной странице, отступы `paddingStart`/`paddingEnd`, сумма = `total * rowH`; +- LRU не держит больше 5 страниц и не выбрасывает страницы окна; +- параметры запроса: пустые не передаются, фильтр «ССП» заменяет `org_ids`, `view=compact` всегда; +- отбрасывание ответа устаревшего `generation`; +- «показывать ли Найдено»; +- существующий `svod2Styles.test.js` зелёный. + +**PR 2, руками на стенде** (GO и RF, admin и dfip): +- «Все» и выбор организаций, «Загрузить»/«Обновить»; +- перенос ползунка в середину и в конец (номера строк по порядку, последняя строка у нижнего края); +- поиск, три фильтра, их сочетание, «Найдено N из M»; +- быстрый ввод с последующим выбором фильтра (без мигания старого результата); +- переключение вкладок, единицы, «Перейти к задачам»; +- горизонтальная прокрутка (закреплённые колонки, полосы, группы); +- ошибка страницы (остановить API во время прокрутки); +- скриншоты «до/после» первого экрана и трёх горизонтальных позиций. + +**PR 3.** +- Сценарии сначала прогнать с `PERF_REPEATS=1` на сборке «до» и «после», чтобы метрики писались, а `incomplete` не появлялось. +- `svod2_paging_check.py` — зелёный на масштабах 1 и 2. + +## Замер до/после + +**Базы:** +- «до» — вершина AURORA-1842 (`399234a` или её ребейзнутая версия, если 1836 вольётся раньше); +- «после» — вершина PR 3. + +Код сценариев в обоих прогонах один и тот же — из PR 3. Для «до» — временный worktree на вершине 1842 и `git checkout <ветка PR 3> -- perf/` в нём, без коммита. + +**Порядок на одних данных:** +1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` (~12 мин) → `verify`. Своды мерить до `grow`: `grow` доращивает AHR, который попадает в своды. +2. Скопировать `results/manifest-s1.json` в оба worktree; при желании снять `pg_dump -Fc`. +3. «До»: в worktree 1842 + perf из PR 3 поднять `api`, `web-build`, `web`. +4. Остановить `api`/`web`. +5. «После»: в worktree PR 3 `alembic upgrade head` поверх тех же данных, поднять `api`, `web-build`, `web`, прогнать `svod2_paging_check.py`, затем замеры. +6. То же для масштаба 2: `reset` + генерация ~45–70 мин. + +**Между «до» и «после» не вливать AURORA-1917 и AURORA-1836** (или переснять «до» после rebase). + +**Команды** (из `perf/e2e`, затем `cd .. && .venv/bin/python -m perfkit.aggregate results/`): + +| что | команда | время на сторону (оценка) | +|---|---|---| +| скролл + время до первых строк | `PERF_RUN_ID=1843--s PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` (RF не помещается в 5 000 шагов, REPORT раздел 6) | s1: «до» ~35 мин, «после» ~15 мин; s2: ×3–4 | +| фильтр | тот же запуск с `--grep filter` | ~5 мин | +| память | `PERF_RUN_ID=1843-mem--s PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 только на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин «до» | +| байты и время API | `.venv/bin/python analysis/api_split.py --scale ` | ~3–5 мин | +| планы SQL | `.venv/bin/python analysis/explain_manual.py --scale --out results/1843-explain-s` | ~3 мин | + +**Пороги** — требования в `specs/`: + +| метрика | порог | +|---|---| +| `heap_used_mb` в точке `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 | +| `first_rows_ms` | ≤ 0,5 × «до», GO и RF, s1 и s2 | +| `bytes(compact)/bytes(full)` той же страницы | ≤ 0,40 | +| первая страница + facets | ≤ 5 % прежнего полного ответа | +| прежний полный запрос | медиана полного времени ≤ 1,10 × «до» | +| `main_thread_ms_per_step` (s1) | ≤ 1,10 × «до» (результат 1842); рядом `placeholder_frame_share` | + +Если `placeholder_frame_share` > 5 %, цену шага «после» считать заниженной: заглушки дешевле строк. Разобрать, успевает ли догрузка (`explain_manual`: стоимость страницы), и при необходимости увеличить упреждение (окно ±2). + +Результаты записать в PR 3 и в отчёт задачи: таблица «до/после» по масштабам, ссылки на `results/…`. + +## Риски / компромиссы + +- **[Каждая страница заново выполняет полный отбор строк — 13 LEFT JOIN + `DISTINCT` по году]** → Время страницы ≈ время отбора, а не сборки `jsonb`. `explain_manual` (`svod2_*_page`) показывает его отдельно. Упреждающая загрузка соседних страниц прячет задержку при обычной прокрутке. Если страница на масштабе 2 дольше ~1 с, следующий шаг — переписать отбор на `EXISTS`-полусоединения с тем же смыслом (сверка — тот же unit-тест отбора и `svod2_paging_check.py`) отдельным коммитом PR 1. +- **[Без новых параметров поиск и фильтры добавляют работу и замедляют прежний ответ]** → Предикаты вида `(p IS NULL OR …)`. Порог +10 % в `api_split` ловит регресс. +- **[`lower()` не переводит кириллицу при `LC_CTYPE=C` на проде]** → Интеграционный тест с «аРЕНДА» падает на такой базе. Обход — `lower(x COLLATE "und-x-icu")`, если PG собран с ICU. Узнать `LC_CTYPE` прод-базы до выкатки PR 1 (вопрос к эксплуатации, ответ не меняет дизайн). +- **[Данные меняются между запросами страниц: строки сдвигаются, на стыке страниц строка может повториться или пропасть]** → Свод — просмотр, «Обновить» даёт согласованный снимок. `total` обновляется по последнему ответу. Ключ строки (`source_form-source_id-line_id`) не даёт React смешать строки. +- **[Сдвиг среза отступами в MRT ведёт себя иначе, чем ожидается: прыжки, пустоты]** → Проверка в первой задаче PR 2. Запасной вариант `#6` — массив длины `total`. +- **[Окно опирается на фиксированную высоту строки, а 1836 меняет её с 37 px на 2,75 rem]** → Единый источник высоты. Тест суммы отступов. Чек-лист rebase выше. +- **[Замер шага «после» занижен заглушками]** → Метрика `placeholder_frame_share`, порог 5 % (см. «Замер»). +- **[Тест фильтра свода 2 «стабилизируется» до ответа сервера (первая мутация — индикатор загрузки)]** → Ожидание ответа с `search=` перед `awaitStable`. +- **[Варианты фильтров не учитывают поиск: можно выбрать значение, которое с поиском даёт 0 строк]** → Таблица покажет «Нет данных для выбранных параметров». Сейчас варианты учитывают поиск, это небольшое изменение UX (`#7`). +- **[Поиск по техническим id (`source_id`, `vsp_id`, `expense_item_id`) находит «лишнее», например по «1»]** → Так ищет и сейчас, поведение сохранено. Сужение полей поиска — отдельный вопрос бизнеса. +- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only`/`--repeats`, описание в README. + +## Что оставлено исполнителю + +- Имена моделей ответа, методов сервиса и репозитория, хука и файла чистой логики (предложенные выше — ориентир). +- Как именно вернуть `total` при пустой странице (второй вызов или `LEFT JOIN` к счётчику) — при условии одного SQL на обычную страницу. +- Оформление заглушки (фон, «бегущая» полоса или ровный цвет) — при прозрачном тексте и той же высоте. +- Где сэмплировать `placeholder_frame_share`: в пробе страницы или колбэком шага `scrollThrough`. +- Точные значения `PAGE_SIZE` (200–500) и окна (±1…±2), если замер покажет выигрыш. Изменение записать в решения без обсуждения. + +## Decisions made without discussion + +1. **2026-10-28 — новый режим API включается наличием любого из новых параметров, в том числе явного `view=full`. Поле `total` есть только в новом режиме.** + Обоснование: требование «без новых параметров ответ байт-в-байт прежний». `limit` — прежний параметр, привязать `total` к нему значило бы изменить прежний ответ. + Цена ошибки: если нужен `total` для прежнего полного вида, его даёт `view=full` — дёшево. +2. **2026-10-28 — фильтр «ССП» передаётся сужением `org_ids` до id организации, а не параметром `org_name`. Значения `smeta_type`/`smeta_direction` в API — подписи с экрана (`АХР`, `Поддержка`…), а не коды БД.** + Обоснование: `org_unit.title` не уникален, `org_ids` уже есть и проверяется на права. Подписи совпадают с `data.header` и вариантами — перевод не нужен. + Цена ошибки: при переименовании подписей в SQL нужно править валидацию API — одна строка. +3. **2026-10-28 — общий отбор строк выносится в `v3.svod2_lines` (одна копия условий для страниц GO/RF и вариантов), без `SET search_path`, чтобы функция могла встраиваться. `v_svod2_go_export` сохраняет свою копию.** + Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами. + Цена ошибки: если встраивание не срабатывает и отбор заметно дорожает, вернуть копии в каждую функцию — час работы. Сравнение — `explain_manual`. +4. **2026-10-28 — компактный вид — та же вложенная форма `data`, только нужные пути, без ключей `null` (`jsonb_strip_nulls`). Строится проекцией полного `jsonb` в SQL (`v3.svod2_compact`), а не массивом значений и не отдельной сборкой из таблиц.** + Обоснование: фронт читает те же пути (`valueAt`); `null` и отсутствие ключа отображаются одинаково (`formatValue`, `textColumn`); у свода 2 нет WS-слияния, которое зависело бы от явных `null` (зависимость из 1848 — только у `RealtimeTable`). Проверка «компакт = проекция полного» тривиальна. Ожидаемо ~1–1,5 КБ на строку против ~5,5 КБ. + Цена ошибки: если 40 % не набирается, переход на массив значений меняет только `svod2_compact` и маппинг на фронте. +5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU не больше 5 страниц на вкладку.** + Обоснование: 300 строк — около 8 экранов и примерно 0,3–0,45 МБ компактного JSON. На шаг прокрутки 200 px граница страницы приходится примерно раз в 55 шагов, упреждения на соседнюю страницу хватает при ответе < 2–3 с. В памяти ≤ 1 500 строк данных и ≤ 900 строк в таблице. + Цена ошибки: подбор констант по замеру, правка двух чисел. +6. **2026-10-28 — окно реализуется срезом данных и отступами `paddingStart`/`paddingEnd` виртуализатора строк. Запасной вариант — массив длины `total` с заглушками.** + Обоснование: только срез даёт память O(окна) и для объектов строк TanStack, а не только для данных. Накопительная бесконечная прокрутка отвергнута: после прокрутки до конца в памяти снова весь ответ, что противоречит решению 28.10. + Цена ошибки: если срез не заработает с MRT 3.2.1, запасной вариант дороже по памяти (десятки МБ объектов строк на масштабе 2) — порог памяти может не выполниться. Тогда это вопрос к человеку. +7. **2026-10-28 — варианты фильтров считаются по году и организациям без учёта поиска и других фильтров. `NULL`-значения в варианты не попадают.** + Обоснование: один запрос вариантов на «Загрузить» вместо запроса на каждое изменение. M в «Найдено N из M» — тот же ответ. + Цена ошибки: если бизнесу нужны «перекрёстные» варианты, `facets` получает те же `search`/фильтры, а фронт перезапрашивает его при изменениях — полдня. +8. **2026-10-28 — поиск ищет и в тех полях `contract_summary` (`counterparty`, `reference`, `deadline`, `comment`), которые на клиенте сейчас фактически не ищутся: спред `{...contract_summary, ...contract_detail}` (`Svod2Page.jsx:284-288`) перекрывает их одноимёнными полями `contract_detail`. Числа сравниваются по тексту из БД (`1234.50`), а не по JS-записи (`1234.5`).** + Обоснование: «по тем же полям» понимается как «по тем же группам полей»; повторять перекрытие — значит повторять ошибку. + Цена ошибки: поиск находит немного больше строк, чем раньше; сузить — одна строка SQL. +9. **2026-10-28 — «Найдено N из M» считает поиск и колоночные фильтры вместе (раньше — только поиск).** + Обоснование: N приходит с сервера одним `total` по всем условиям, отдельно «только поиск» пришлось бы считать вторым запросом. + Цена ошибки: подпись иначе ведёт себя при одном колоночном фильтре без поиска. Вернуть прежнее — дополнительный `total` без фильтров. +10. **2026-10-28 — пороги успеха:** + - память s1 ≤ 30 % «до», s2 ≤ 1,25 × s1; + - время до первых строк ≤ 50 % «до»; + - компакт ≤ 40 % полного; + - первая страница + варианты ≤ 5 % полного ответа; + - прежний режим не медленнее +10 %; + - шаг прокрутки ≤ 1,10 × результата 1842; + - заглушки в кадре ≤ 5 %. + + Обоснование: оценки по REPORT (М2, М9, М10). Перечисленные пороги консервативны, ожидаемый эффект больше. 10 % — шум при n=2. + Цена ошибки: порог окажется недостижим или слишком слаб — пересмотр по фактическим цифрам с человеком, код не меняется. +11. **2026-10-28 — стенд: код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`. `json_bytes_sheet` сохраняет смысл «максимум одного ответа», суммарные байты — новые метрики.** + Обоснование: сопоставимость метрик и прежних отчётов. + Цена ошибки: при расхождении кода сценариев «до» нужно переснять — около часа на масштабе 1. +12. **2026-10-28 — рабочий номер миграции `0053`; окончательный — «голова `test` + 1» при rebase.** + Обоснование: 0050 занят тремя PR, их порядок неизвестен. + Цена ошибки: переименование двух файлов и правка тестов. +13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2 в откатываемой сессии, а не полагаются на `fixture.sql`.** + Обоснование: в фикстуре нет ни одной строки, проходящей отбор свода 2 за 2026 год. Тесты со `skip` при отсутствии данных ничего не доказывали бы. + Цена ошибки: нет. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/proposal.md b/openspec/changes/aurora-1843-svod2-server-paging/proposal.md new file mode 100644 index 0000000..f031d6a --- /dev/null +++ b/openspec/changes/aurora-1843-svod2-server-paging/proposal.md @@ -0,0 +1,102 @@ +# Proposal: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ + +Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 (PR #217) → AURORA-1842 (PR #227) → эта задача. + +## Why + +При выборе «Все ССП/РФ» свод 2 (`/svod/2`) получает весь лист одним ответом и держит его в памяти вкладки целиком. Поиск и колоночные фильтры при этом считаются в браузере по всем строкам. + +Цифры из отчёта о производительности (`perf/REPORT.md`, М2, М9, М10; масштаб 1 и 2, CPU 1×): + +| | масштаб 1 | масштаб 2 | +|---|---|---| +| ответ GO, распакованный | 30,5 МБ (5 563 строки, ~5,5 КБ на строку) | 123,2 МБ | +| ответ RF, распакованный | 52,7 МБ (9 497 строк) | 195,1 МБ | +| время ответа GO / RF | 1,06 / 1,8 с | 4,5–4,8 / 7,1–7,3 с | +| доля Python в ответе (pydantic на строку, JSON, gzip) | 32 % (0,34–0,58 с) | растёт с размером | +| шаг скролла GO / RF (до 1842) | 184 / 187 мс | 251 / 214–256 мс | +| ввод в поиск, главный поток | 0,2–0,3 с | GO 187–266 мс, RF 281–325 мс | + +Пока открыт маршрут `/svod/2`, состояние страницы держит ответы обеих вкладок. GO «все» и RF «все» вместе дают около 83 МБ JSON на масштабе 1 и около 318 МБ на масштабе 2, плюс объекты строк таблицы. Рост шага на масштабе 2 при том же числе видимых строк отчёт связывает со сборкой мусора на такой куче: GC занимает 17 мс на шаг против 4–5 мс на листах. Это не подтверждено, но с объёмом данных согласуется. + +Задача была аналитической: «предложить вариант с оценкой». 28.10 человек решил делать сразу реализацию с максимальным эффектом. Выбраны варианты C (серверная пагинация, поиск и фильтры на сервере) и B (компактный ответ) из черновика разведки. Вариант A (только политика кэша на фронте) не нужен. + +## What Changes + +- **API `GET /api/v1/svod/2/{sheet}/{year}`.** У эндпоинта уже есть `org_ids`, `offset`, `limit`, `sort_by` и `sort_direction`. Добавляются необязательные параметры: + - `search` — поиск подстроки без учёта регистра по тем же полям, что ищет экран сейчас; + - `smeta_type` и `smeta_direction` — фильтры по виду сметы и направлению; + - `view=full|compact` — `compact` отдаёт только поля, которые экран показывает. + + Если в запросе есть хотя бы один новый параметр, в ответе появляется поле `total` — число строк по всем условиям запроса без учёта `offset`/`limit`. `count`, как и раньше, равен длине `result`. Фильтр по организации — это сужение `org_ids` (параметр уже есть), с прежней проверкой прав. +- **Без новых параметров ответ байт-в-байт прежний.** Это нужно Excel-выгрузке RF, интеграционным тестам, `perfkit.bench` и `perfkit.gen.verify`. +- **Новый эндпоинт `GET /api/v1/svod/2/{sheet}/{year}/facets`.** Отдаёт общее число строк и варианты трёх колоночных фильтров («Вид сметы», «Направление», «ССП») для выбранного года и организаций. +- **SQL.** Новая миграция: + - `DROP` прежних `v3.v_svod2_go` и `v3.v_svod2_rf` (6 аргументов) и `CREATE` с новой сигнатурой, без перегрузок; + - общий отбор строк свода 2 выносится в одну функцию, её используют и страницы, и варианты фильтров; + - функция вариантов фильтров и проекция компактной строки. + + Всё совместимо с PostgreSQL 15. Отбор строк (какие строки попадают в свод) не меняется. +- **Экран свода 2.** + - Таблица и колонки остаются прежними. + - После «Загрузить» приходит первая страница строк, остальные догружаются при прокрутке. + - В памяти держится окно из нескольких страниц вокруг видимой области, а не весь ответ. Полоса прокрутки соответствует полному числу строк. + - Поиск (debounce 300 мс уже есть) и три колоночных фильтра выполняются на сервере, варианты фильтров приходят с сервера. + - «Найдено N из M» строится из `total` и считает и поиск, и колоночные фильтры. + - Свод 2 больше не кладёт ответы в кэш `SvodContext`. Кэш остаётся только у свода 1, его поведение не меняется. +- **Стенд замеров `perf/`.** + - `scroll.spec.ts`: свод 2 учитывает строки-заглушки, добавлены метрики времени до первых строк и байтов, фильтр ждёт ответа сервера. + - Новый сценарий `svod2-memory.spec.ts` (GO «все» → RF «все» → GO, куча после GC). + - В `api_split.py`, `explain_manual.py` и `scale_compare.py` добавлены страницы, компактный вид и варианты фильтров. + - Скрипт сверки серверной выдачи со старым полным ответом на данных стенда. +- **Три PR в стеке:** + 1. сервер — SQL, API, тесты; + 2. фронт — догрузка, поиск, фильтры; + 3. замер и сценарии `perf/`. + +### Что НЕ меняется + +- Excel-выгрузка свода 2 (`GET /api/v1/export/summary/2/...`) — ни контракт, ни содержимое. Кнопки выгрузки свода 2 на фронте нет, поэтому под экранные фильтры её не подгоняем (решение 28.10). +- Какие строки попадают в свод 2. Отбор по наличию данных — миграция 0049. Отбор RF по типу формы — баг O-5, он решается отдельно в AURORA-1917. +- Свод 1, сводные проектов, `SvodFilters` (общий для сводов 1 и 2), кэш `SvodContext` для свода 1. +- Сортировка свода 2: на экране она выключена (`enableSorting: false`), на сервере остаётся по умолчанию `org_name asc`. +- Набор, порядок, ширины и форматирование колонок, единицы (руб./тыс./млн), закреплённые колонки, «Перейти к задачам». Подписи «Все ССП/РФ», «Загрузить» и «Обновить» и порядок действий пользователя (выбрать организации → «Загрузить») тоже прежние. + +## Capabilities + +### New Capabilities +- `summary2-api`: контракт API свода 2. Постраничная выдача с `total`, серверный поиск и фильтры, компактное представление строки, варианты фильтров, права доступа, совместимость прежнего ответа, критерии размера ответа. +- `summary2-screen`: поведение экрана свода 2. Догрузка строк страницами при прокрутке, ограниченное окно строк в памяти, поиск и фильтры через сервер, «Найдено N из M», ошибки. Критерии производительности: память, время до первых строк, шаг скролла. + +### Modified Capabilities +Нет: `openspec/specs/` пока пуст. + +## Impact + +- **Бэк:** + - `api/src/api/v1/summary.py` (`get_summary2` и новый эндпоинт вариантов фильтров); + - `api/src/services/summary_service.py`; + - `api/src/repository/summary_repository.py`; + - `api/src/domain/schemas.py` (модели ответа с `total` и вариантов фильтров); + - новая миграция `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`. Номер выбирается при rebase, см. design.md. +- **Тесты бэка:** + - `api/tests/integration/test_summary_api_smoke.py` и новые интеграционные тесты со своими данными свода 2; + - `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить на новую миграцию); + - unit-тесты сервиса и валидации параметров. +- **Фронт:** + - `web/src/api/summary.js`; + - `web/src/pages/Svod2Page/Svod2Page.jsx`; + - новые `web/src/pages/Svod2Page/svod2Paging.js` (чистая логика окна и параметров, с `node --test`) и хук загрузки страниц; + - `svod2Styles.js` (стиль строк-заглушек). +- **Стенд:** + - `perf/e2e/scenarios/scroll.spec.ts`; + - новый `perf/e2e/scenarios/svod2-memory.spec.ts`; + - `perf/analysis/api_split.py`, `perf/analysis/explain_manual.py`, `perf/analysis/scale_compare.py`; + - новый `perf/analysis/svod2_paging_check.py`; + - `perf/README.md`. +- **Связанные задачи:** + - **AURORA-1842** (PR #227, база стека) — виртуализация колонок и CSS-стили свода 2; от неё берётся результат шага скролла, который нельзя ухудшить. + - **AURORA-1836** (PR #222, от `test`) правит в `Svod2Page.jsx` высоту строки (2,75 rem) и overscan строк. Окно страниц опирается на фиксированную высоту строки, при rebase брать её из 1836. + - **AURORA-1917** (баг O-5: в «РФ» попадают строки ССП) уменьшит ответ RF. Её нельзя вливать между замерами «до» и «после» 1843. + - **AURORA-1848** (тяжёлые GET без pydantic на строку) правит тот же обработчик `get_summary2`, нужна координация. + - **AURORA-1831/1844/1846** заняли номер миграции 0050. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md new file mode 100644 index 0000000..5527a60 --- /dev/null +++ b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md @@ -0,0 +1,136 @@ +## 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 от замера «до» diff --git a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md new file mode 100644 index 0000000..28156f1 --- /dev/null +++ b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md @@ -0,0 +1,90 @@ +## Purpose + +Поведение экрана свода 2 (`/svod/2`, вкладки «ССП» и «РФ»): загрузка строк страницами при прокрутке с ограниченным объёмом данных в памяти, поиск и колоночные фильтры через сервер, счётчик «Найдено N из M» и критерии производительности экрана (память, время до первых строк, шаг прокрутки). + +## ADDED Requirements + +### Requirement: Загрузка строк страницами при прокрутке +После нажатия «Загрузить» или «Обновить» экран SHALL запрашивать с сервера только страницу строк с начала списка и варианты колоночных фильтров. Полная длина полосы прокрутки SHALL соответствовать общему числу строк по текущим условиям. Строки, которые ещё не пришли, SHALL показываться пустыми строками-заглушками той же высоты: без значений и без прочерков, которыми помечаются пустые данные. Когда видимая область доходит до незагруженных строк, экран SHALL их запросить и заменить заглушки данными, без сдвига прокрутки. В памяти страницы SHALL держаться строки не более чем фиксированного числа страниц вокруг видимой области, независимо от общего числа строк. Порядок действий пользователя (выбор года и ССП/РФ, пункт «Все ССП/РФ», кнопка «Загрузить») и подписи не меняются. + +#### Scenario: Первая загрузка «Все ССП/РФ» +- **WHEN** пользователь выбирает «Все ССП/РФ» на вкладке «ССП» и нажимает «Загрузить» +- **THEN** экран отправляет один запрос страницы с `offset=0` и один запрос вариантов фильтров, показывает первые строки, а высота прокрутки соответствует `total` строкам + +#### Scenario: Прокрутка в середину и в конец +- **WHEN** пользователь перетаскивает полосу прокрутки в середину, а затем в самый конец списка +- **THEN** в видимой области сначала могут быть заглушки, затем появляются строки с теми номерами по порядку, что и в полном списке, а последняя строка списка видна у нижнего края + +#### Scenario: Память не растёт при прокрутке до конца +- **WHEN** пользователь прокручивает лист RF «все» до конца и обратно +- **THEN** число строк с данными, которые экран держит в памяти, не превышает предела окна (число страниц окна × размер страницы) + +### Requirement: Поиск и колоночные фильтры через сервер +Изменение строки поиска (после существующей задержки ввода 300 мс) и изменение колоночного фильтра «Вид сметы», «Направление» или «ССП» SHALL перезапрашивать данные с сервера с начала списка, если по текущим году и ССП/РФ данные уже загружены. Если данные не загружены, перезапроса не происходит, как и сейчас. Варианты в колоночных фильтрах SHALL браться из ответа сервера с вариантами для текущих года и ССП/РФ. Ответ на устаревший запрос (после него условия уже поменялись) SHALL NOT попадать на экран. Как и сейчас, изменение поиска, года или ССП/РФ сбрасывает колоночные фильтры текущей вкладки. + +#### Scenario: Поиск +- **WHEN** данные загружены и пользователь вводит «аренда» в поле поиска +- **THEN** через 300 мс после последнего нажатия экран запрашивает первую страницу с этим поиском и показывает только найденные строки с начала списка + +#### Scenario: Быстрая смена условий +- **WHEN** пользователь вводит поиск и, не дожидаясь ответа, выбирает колоночный фильтр «Направление» +- **THEN** на экране остаются строки, отвечающие обоим условиям, а ответ на первый запрос не показывается + +#### Scenario: Варианты фильтра «ССП» +- **WHEN** загружен лист с тремя организациями +- **THEN** в фильтре «ССП» перечислены ровно эти три организации, и выбор одной из них оставляет только её строки + +### Requirement: Счётчик «Найдено N из M» +Когда задан поиск или хотя бы один колоночный фильтр и число строк по условиям N отличается от общего числа строк M по текущим году и ССП/РФ, экран SHALL показывать «Найдено N из M строк». N берётся из `total` ответа страницы, M — из ответа с вариантами фильтров. Числа форматируются с разделителями `ru-RU`. Иначе подпись не показывается. + +#### Scenario: Счётчик учитывает и поиск, и фильтры +- **WHEN** загружено 5 563 строки, задан поиск и фильтр «Вид сметы», и по ним найдено 120 строк +- **THEN** показано «Найдено 120 из 5 563 строк» + +### Requirement: Вкладки и обновление +У каждой вкладки («ССП», «РФ») SHALL сохраняться своё состояние (условия, загруженное окно строк, общее число строк). Возврат на вкладку показывает её строки без нового запроса, если её условия не менялись. «Обновить» SHALL перезапрашивать варианты фильтров и строки текущей вкладки с сервера. Смена года или ССП/РФ, как и сейчас, требует нажать «Загрузить». Свод 2 SHALL NOT сохранять полные ответы в общем кэше сводов. Поведение свода 1 не меняется. + +#### Scenario: Переключение вкладок +- **WHEN** загружены «ССП» и «РФ», пользователь возвращается на «ССП» +- **THEN** строки «ССП» видны сразу, без запроса страницы + +#### Scenario: Обновить +- **WHEN** пользователь нажимает «Обновить» +- **THEN** экран заново запрашивает варианты фильтров и первую страницу и показывает актуальные данные с начала списка + +### Requirement: Неизменное отображение строк +Колонки, их группы, порядок, ширины, закреплённые колонки, форматирование чисел и прочерков (`-` для нуля, `—` для пустого), единицы (руб./тыс./млн), бейджи вида сметы и ССП, «Перейти к задачам» SHALL выглядеть и работать так же, как до изменения. Отображение SHALL NOT зависеть от того, пришло ли значение `null` или поле отсутствует. + +#### Scenario: Визуальный паритет +- **WHEN** на одних данных открыть GO «все» до и после изменения и сравнить первые экраны и три позиции горизонтальной прокрутки +- **THEN** значения ячеек, прочерки и оформление совпадают + +### Requirement: Ошибки загрузки +Если не удалась первая страница или варианты фильтров, экран SHALL показывать, как и сейчас, сообщение об ошибке с кнопкой «Повторить». Если не удалась одна из следующих страниц, экран SHALL оставить на её месте заглушки, сообщить об ошибке всплывающим уведомлением и повторить запрос, когда эта область снова окажется в видимой зоне. + +#### Scenario: Ошибка догрузки +- **WHEN** запрос страницы при прокрутке завершился ошибкой 500 +- **THEN** уже показанные строки остаются, на месте страницы — заглушки, показано уведомление; при повторной прокрутке к этому месту запрос повторяется + +### Requirement: Память страницы свода 2 +На стенде замеров сценарий `perf/e2e/scenarios/svod2-memory.spec.ts` (роль dfip: GO «Все ССП/РФ» → «Загрузить» → вкладка RF «Все ССП/РФ» → «Загрузить» → вкладка GO, затем сборка мусора) SHALL показывать JS-кучу `heap_used_mb` в конечной точке: +- на масштабе 1 — не больше 30 % значения «до»; +- на масштабе 2 — не больше 1,25 значения «после» на масштабе 1, то есть куча не растёт вместе с объёмом данных. + +#### Scenario: Замер памяти до и после +- **WHEN** сценарий выполнен на одних данных для сборки «до» (вершина AURORA-1842) и «после», на масштабах 1 и 2, по 3 повтора +- **THEN** медиана `heap_used_mb` в конечной точке «после» на масштабе 1 не больше 0,30 × «до», а на масштабе 2 — не больше 1,25 × «после» на масштабе 1 + +### Requirement: Время до первых строк +Время от нажатия «Загрузить» при «Все ССП/РФ» до появления в таблице не менее 20 строк с данными (заглушки не считаются) SHALL быть не больше 50 % значения «до» для GO и RF на масштабах 1 и 2. + +#### Scenario: Замер времени до первых строк +- **WHEN** `scroll.spec.ts` для `svod/svod2_GO` и `svod/svod2_RF` (роль dfip) пишет метрику `first_rows_ms` для сборок «до» и «после» на одних данных +- **THEN** медиана `first_rows_ms` «после» не больше 0,5 × медианы «до» для каждого листа и масштаба + +### Requirement: Шаг прокрутки не хуже AURORA-1842 +Цена шага вертикальной прокрутки свода 2 (`main_thread_ms_per_step` в `scroll.spec.ts`, роль dfip, CPU 1×) SHALL быть не больше 1,10 значения «до» (вершина AURORA-1842, снятая тем же кодом сценария на тех же данных) для GO и RF на масштабе 1. Доля кадров с видимыми заглушками SHALL записываться рядом с ценой шага. + +#### Scenario: Замер шага прокрутки +- **WHEN** `scroll.spec.ts` с `PERF_TARGETS='svod/svod2_*'`, `PERF_ROLES=dfip`, `--grep-invert filter` выполнен для «до» и «после» +- **THEN** медиана `main_thread_ms_per_step` «после» не больше 1,10 × «до» для каждого листа, а в записи есть метрика доли кадров с заглушками -- 2.47.2 From 9d68cd5e1a9f98e6181aefcc62e11bcbe637ee9b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:48:30 +0400 Subject: [PATCH 02/17] =?UTF-8?q?docs(openspec):=20aurora-1843=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B4=D0=B0=202=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9,=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2028.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск, колоночные фильтры, варианты фильтров и «Найдено N из M» — с точной семантикой экрана (matchesSearch, MRT equals, faceting TanStack); регистр без зависимости от локали БД; пороги — цели, а не условие слияния; решения контроллера (три фазы/ветки, prefetch страницы 1, окружение тестов). Co-Authored-By: Claude Opus 5.5 --- .../aurora-1843-svod2-server-paging/design.md | 826 ++++++++++-------- .../proposal.md | 24 +- .../specs/summary2-api/spec.md | 125 +-- .../specs/summary2-screen/spec.md | 104 ++- 4 files changed, 620 insertions(+), 459 deletions(-) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index c7731bf..0384971 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -1,113 +1,171 @@ # Design: AURORA-1843 — свод 2: серверная постраничная загрузка и компактный ответ -Мотивация и цифры — в `proposal.md`, требования — в `specs/summary2-api/spec.md` и `specs/summary2-screen/spec.md`. Ссылки `file:line` ниже даны по вершине ветки `feat/236-aurora-1843-svod2-server-paging`: это `399234a`, вершина AURORA-1842. +Мотивация и цифры — в `proposal.md`, требования — в `specs/summary2-api/spec.md` и `specs/summary2-screen/spec.md`. Ссылки `file:line` ниже даны по вершине `399234a` — это вершина AURORA-1842, база ветки `feat/236-aurora-1843-svod2-server-paging`. ## Контекст: как устроено сейчас -**API.** `GET /api/v1/svod/2/{sheet}/{year}`: +### API +`GET /api/v1/svod/2/{sheet}/{year}`: - обработчик — `api/src/api/v1/summary.py:93-146`. Он уже принимает `org_ids`, `offset`, `limit` (`:99-100`), `sort_by` и `sort_direction`; - `count = len(rows)` (`:143-146`); -- ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`), а `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`. +- ответ собирается через pydantic-модель на каждую строку (`_rows_to_sheet_response`, `:28-36`); +- `response_model` выводится из аннотации `-> BaseListResponse[SheetResponse]`. -Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`): -- через `_allowed_org_ids` (`:73-88`) пересекает запрошенные организации с разрешёнными; -- для администратора `None` означает «все»; -- для остальных пустое пересечение даёт `[]`. +Сервис `SummaryService.get_svod2_rows` (`api/src/services/summary_service.py:31-53`) пересекает запрошенные организации с разрешёнными через `_allowed_org_ids` (`:73-88`): +- у администратора `None` означает «все»; +- у остальных пустое пересечение даёт `[]`. -Репозиторий — `api/src/repository/summary_repository.py:34-77`, это `SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов)`. +Репозиторий (`api/src/repository/summary_repository.py:34-77`) выполняет `SELECT 'INPUT', 3, sort_order, data FROM v3.v_svod2_go|rf(6 аргументов)`. Числа `jsonb` asyncpg разбирает через `json.loads` во `float`/`int`, затем pydantic сериализует их обратно в JSON. Экран получает значения как `Number` двойной точности: `1234.50` в БД превращается в `1234.5`. -**SQL.** Актуальные версии `v3.v_svod2_go` и `v3.v_svod2_rf` лежат в `api/alembic/versions/sql/0049_summary2_related_records.sql:6-303` и `:305-595`. Каждая собрана из CTE: -1. `eligible_lines` — `DISTINCT` по 13 LEFT JOIN «наличия данных». GO берёт FORM_1 и FORM_4 (`:71`), RF — FORM_2 (`:369`). Значения вычисляются тут же: `smeta_type` из `ei.sheet`, `org_name = ou.title`; -2. `numbered_lines` — `row_number()` по `org_name`/`item_id` или `section_code`, затем `form_type_code`, `form_id`, `line_id` (`:151-168`); -3. `page AS MATERIALIZED` — `LIMIT p_limit OFFSET p_offset` (`:169-173`); -4. сборка большого `jsonb` только для строк страницы (`:179-302`). +### SQL +Актуальные `v3.v_svod2_go` и `v3.v_svod2_rf` лежат в `api/alembic/versions/sql/0049_summary2_related_records.sql`: `:6-303` и `:305-595`. Каждая функция собрана из четырёх CTE: +1. `eligible_lines`: `DISTINCT` по 13 LEFT JOIN «наличия данных». Для GO берутся FORM_1 и FORM_4 (`:71`), для RF — FORM_2 (`:369`); здесь же вычисляются `smeta_type` из `ei.sheet` и `org_name = ou.title`. +2. `numbered_lines`: `row_number()` (`:151-168`). +3. `page AS MATERIALIZED` с `LIMIT`/`OFFSET` (`:169-173`). +4. Сборка большого `jsonb` только для строк страницы (`:179-302`). -Значит, стоимость страницы — это полный отбор плюс сборка `jsonb` только для этой страницы. `v3.v_svod2_go_export` (`:597-902`) — отдельная компактная функция для Excel GO, её не трогаем. Прежние перегрузки этих функций уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре: `0022_summary2_contract_rows.sql:11-13`, `:200-202`. +Значит, стоимость страницы — полный отбор плюс сборка `jsonb` только для этой страницы. -**Кто зовёт `GET /svod/2/...` и `get_svod2_rows`:** -- фронт — только `Svod2Page` через `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`, без `offset`/`limit`); -- Excel RF — `export_service.py:236-245`, `offset=None`, `limit=None`; -- интеграционные тесты — `api/tests/integration/test_summary_api_smoke.py:44-140`; -- стенд: `perfkit.gen.verify` (`perf/perfkit/gen/verify.py:146-148`), `perfkit.bench` (повторяет URL из сетевого лога), `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`. +В листе RF `contract_detail` в ответе — это `to_jsonb(cd)`, дополненный `expenses_q1..4 = COALESCE(cd.expenses_qN, ck.expenses_qN)` (`:543-547`). В GO — просто `to_jsonb(cd)`. -**Фронт.** `web/src/pages/Svod2Page/Svod2Page.jsx`: -- состояние вкладок `tableStateBySheet` держит `rows` целиком (`:21-22`, `:302`, `:345-348`); -- `loadSummary` (`:335-367`) берёт ответ из кэша `SvodContext` (`web/src/pages/SvodPage/SvodContext.jsx:46-70`). Ключ кэша — лист + год + организации, кэш очищается при размонтировании провайдера маршрута `/svod/2` (`web/src/app/Routes.jsx:54-59`). `SvodContext` и `SvodFilters` общие со сводом 1; -- поиск — `matchesSearch` (`:283-294`) по `{...header, ...contract_summary, ...contract_detail}`, фильтрация в `filteredRows` (`:387-391`), debounce ввода 300 мс в `SvodFilters.jsx:68-77`; -- три колоночных select-фильтра — `smeta_type`, `smeta_direction`, `org_name` (`:158-176`) — используют `enableFacetedValues` (`:400`). Сортировка выключена (`:411`); -- «Найдено N из M» (`:502-506`) считает только поиск, колоночные фильтры не учитывает; -- изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`); -- данные без «Загрузить» не грузятся (`:448-455`). «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`), сервер подставляет все разрешённые; -- виртуализация строк — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: `ROW_HEIGHT = 37`, `measureElement` по `offsetHeight`). Виртуализация колонок из 1842 — `:33-34`, `:407-408`; -- кнопки Excel-выгрузки свода 2 на фронте нет: по `web/src` нет ни `export/summary`, ни `summary/2`. +`v3.v_svod2_go_export` (`:597-902`) — отдельная функция для Excel GO, её не трогаем. Прежние перегрузки уже удалялись `DROP FUNCTION IF EXISTS` по сигнатуре (`0022_summary2_contract_rows.sql:11-13`, `:200-202`). -**Стенд.** `perf/e2e/scenarios/scroll.spec.ts`: -- для свода 2 кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`); -- ждёт `MIN_ROWS` строк `tbody tr` (`waitRows`); -- `json_bytes_sheet` — максимум байтов одного ответа по `respRe` (`:176-184`); -- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`armStable`/`awaitStable`, `:113-150`, `:221-227`); -- времени до первых строк сценарий не пишет; -- сценария памяти для сводов нет: `memory.spec.ts` их не открывает. +### Кто зовёт `GET /svod/2/...` и `get_svod2_rows` +- фронт — только `Svod2Page` → `SvodContext.getSummary` → `SummaryApi.get` (`web/src/api/summary.js:4-7`), без `offset` и `limit`; +- Excel RF (`export_service.py:236-245`); +- `api/tests/integration/test_summary_api_smoke.py:44-140`; +- стенд: `perfkit.gen.verify` (`verify.py:146-148`), `perfkit.bench`, `perf/analysis/api_split.py:51-52`, `perf/analysis/scale_compare.py:43-44`. -`perf/analysis/explain_manual.py` функции свода 2 **не** использует (запросы — только FORM_2/AHR и FORM_4/CAP, `:59-81`), поэтому смена сигнатуры его не ломает. +### Фронт: точная текущая семантика +`web/src/pages/Svod2Page/Svod2Page.jsx` и MRT 3.2.1 с TanStack Table 8.20.5. -**Тестовые данные.** В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2: единственная строка FORM_1 относится к 2027 году и без данных. Смоук-тесты свода 2 фактически проверяют пустой ответ. +**Поиск — `matchesSearch` (`:283-294`).** +- Значения берутся из `Object.values({ ...row.data?.header, ...row.data?.contract_summary, ...row.data?.contract_detail })` и проверяются как `String(value ?? '').toLowerCase().includes(query)`. +- Запрос — `search.trim().toLowerCase()`; пустой не фильтрует (`:387-391`). +- Фильтрация идёт **до** таблицы: в MRT уходят уже отфильтрованные `filteredRows`. +- Ввод с задержкой 300 мс (`SvodFilters.jsx:68-77`). +- Из-за слияния объектов поля `contract_summary`, одноимённые полям `contract_detail` (`counterparty`, `reference`, `deadline`, `comment`), перекрываются и **не ищутся**. Это находка, см. раздел «Найденные странности». + +**Колоночные фильтры** — три `filterVariant: 'select'` (`:158-176`): `smeta_type`, `smeta_direction`, `org_name`. +- Фильтр MRT `equals` (`material-react-table/dist/index.esm.js:663-667`): `row.getValue(id)?.toString().toLowerCase().trim() === filterValue.toString().toLowerCase().trim()`. Значение `null` не проходит, пустое значение фильтра снимает фильтр (`autoRemove`). +- Фильтр «ССП» сравнивает **названия**: у двух организаций с одинаковым `title` один вариант, и он отбирает обе. + +**Варианты фильтров** — `enableFacetedValues: true` (`:400`). +- TanStack `getFacetedRowModel` (`@tanstack/table-core/build/lib/utils/getFacetedRowModel.js`) берёт строки таблицы (уже отфильтрованные поиском) и применяет фильтры всех колонок, **кроме своей**. +- `getFacetedUniqueValues` считает число строк на каждое точное значение. +- MRT (`useDropdownOptions`, `index.esm.js:203-218`) отбрасывает `null` и `undefined`, сортирует `a.localeCompare(b)` и показывает число в скобках (`:3227-3228`), но только когда у колонки нет `filterSelectOptions`. + +**«Найдено N из M» (`:502-506`).** N = `filteredRows.length` (только поиск, без колоночных фильтров), M = `rows.length`. Подпись показывается при `hasCurrentData && !loading && N !== M`. + +**Сброс колоночных фильтров.** Изменение поиска, года или организаций сбрасывает колоночные фильтры вкладки (`updateCurrentSheetFilters`, `:317-323`). + +**Прокрутка.** Контейнер таблицы один на обе вкладки. Положение прокрутки программно не меняется ни при смене вкладки, ни при поиске, ни при «Загрузить» (в `useMRT_Effects` сброса тоже нет). При уменьшении данных браузер сам ограничивает `scrollTop`. + +**Загрузка.** +- Без «Загрузить» данных нет (`:448-455`). +- «Все ССП/РФ» даёт `selectedOrganizationIds = []` (`:308-311`). +- `loading` выставляется только при «Загрузить»/«Обновить» (`:335-367`). +- Ошибка — `Alert` с «Повторить» (`:456-468`). +- Ответы кэшируются в `SvodContext` (`SvodContext.jsx:46-70`, ключ «лист + год + организации», очищается при уходе с маршрута `/svod/2`, `Routes.jsx:54-59`). +- `SvodContext` и `SvodFilters` общие со сводом 1. + +**Виртуализация.** Строки — `ROW_VIRTUALIZER_OPTIONS` (`:26-31`: 37 px, `measureElement` по `offsetHeight`), колонки — из 1842 (`:33-34`, `:407-408`). + +**Кнопки Excel-выгрузки свода 2 на фронте нет.** + +### Стенд +`perf/e2e/scenarios/scroll.spec.ts`: +- кликает «ССП/РФ» → «Все ССП/РФ» → «Загрузить» (`:194-200`); +- ждёт `MIN_ROWS` строк `tbody tr`; +- `json_bytes_sheet` — максимум байтов одного ответа (`:176-184`); +- тест фильтра вводит «1» и ждёт первой мутации и двух тихих кадров (`:113-150`, `:221-227`); +- время до первых строк не пишется. + +Сценария памяти для сводов нет. `perf/analysis/explain_manual.py` функции свода 2 не использует (`:59-81`). + +### Тестовые данные +В `api/tests/sqls/fixture.sql` за 2026 год нет строк, проходящих отбор свода 2. ## Цели / не цели **Цели:** -- память страницы свода 2 ограничена окном строк и не растёт с объёмом данных; -- первые строки появляются за время одной страницы, а не всего ответа; -- байты и Python-время на строку уменьшаются за счёт компактного вида; +- память страницы свода 2 ограничена окном строк; +- первые строки появляются за время одной-двух страниц; +- меньше байтов на строку; - поиск и фильтры не обходят все строки в браузере; +- для пользователя ничего не меняется, кроме ожидания ответа сервера; - прежние потребители API не замечают изменения. **Не цели:** -- менять отбор строк свода 2 (это O-5 / AURORA-1917); +- менять отбор строк свода 2 (O-5, AURORA-1917); +- менять семантику поиска или фильтров, включая исправление перекрытия полей поиска; - адаптировать Excel-выгрузку под экранные фильтры; -- серверная сортировка по клику на заголовок (на экране её нет); -- отказ от pydantic на строку (это AURORA-1848); -- полнотекстовый или триграммный индекс поиска; -- менять свод 1 и общий `SvodFilters`. +- серверная сортировка; +- отказ от pydantic на строку (AURORA-1848); +- индекс для поиска; +- изменения свода 1 и `SvodFilters`. ## Решения (согласовано 28.10) -1. Вместо аналитики — реализация, варианты C + B черновика. Вариант A (только политика кэша на фронте) не нужен. -2. Сервер. У существующих `offset`/`limit` появляются серверный поиск (по тем же полям, что `matchesSearch` сейчас), три фильтра (`smeta_type`, `smeta_direction`, организация через `org_name`/`org_ids`) и поле `total`. `count` сохраняет прежний смысл (длина `result`). -3. Отдельный лёгкий запрос вариантов для трёх фильтров (facets). -4. Компактный ответ (только поля, которые экран читает или ищет) включается параметром, например `view=compact`. Без новых параметров ответ байт-в-байт прежний. -5. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет. Выгрузку под экранные фильтры не подгоняем. -6. Фронт: та же таблица, строки догружаются страницами при прокрутке. В памяти — окно плюс запас, а не весь ответ. Поиск и фильтры уходят на сервер (debounce 300 мс уже есть), «Найдено N из M» строится из `total`. Кэш `SvodContext` перестаёт копить полные ответы. -7. SQL: новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и сценарий `scroll.spec.ts` (клик «Все ССП/РФ» → «Загрузить») обновляются вместе с UX, чтобы замер оставался сопоставимым. -8. Три PR в стеке: сервер (SQL + API + тесты), фронт (догрузка, поиск, фильтры), замер и сценарии perf. -9. AURORA-1917 (O-5) — отдельная задача. Её нельзя вливать между замерами «до» и «после» 1843. -10. Метрики успеха: память страницы после GO «все» → RF «все» → GO (нужен новый сценарий `svod2-memory.spec.ts`), время до первых строк, байты ответа, шаг прокрутки свода 2 не хуже результата 1842. Масштабы 1 и 2. +1. Вместо аналитики — реализация, варианты C + B черновика; вариант A не нужен. +2. **Сервер:** + - у существующих `offset`/`limit` появляются серверный поиск, три фильтра (`smeta_type`, `smeta_direction`, организация — `org_name`/`org_ids`) и поле `total`; `count` сохраняет прежний смысл; + - отдельный лёгкий запрос вариантов фильтров (facets); + - компактный ответ включается параметром (`view=compact`); + - без новых параметров ответ байт-в-байт прежний. +3. В своде 2 нет строки итогов, сортировка отключена, select-фильтров три, кнопки выгрузки нет — выгрузку под экранные фильтры не подгоняем. +4. **Фронт:** + - та же таблица, строки догружаются страницами при прокрутке, в памяти окно плюс запас; + - поиск и фильтры уходят на сервер (debounce 300 мс уже есть); + - кэш `SvodContext` перестаёт копить полные ответы. +5. **SQL:** новая сигнатура `v_svod2_go`/`v_svod2_rf` → новая миграция с `DROP` старой и `CREATE` новой, совместимая с PG15. `explain_manual.py` и `scroll.spec.ts` обновляются вместе с UX, чтобы замер оставался сопоставимым. +6. **Три PR в стеке:** сервер, фронт, perf. +7. **AURORA-1917 (O-5)** — отдельная задача, её нельзя вливать между замерами «до» и «после». +8. **Метрики:** + - память после GO «все» → RF «все» → GO (новый `svod2-memory.spec.ts`); + - время до первых строк; + - байты ответа; + - шаг прокрутки не хуже 1842; + - масштабы 1 и 2. +9. **Поведение свода 2 для пользователя остаётся абсолютно тем же:** + - варианты фильтров считаются как сейчас: с учётом поиска и фильтров других колонок, без своего, с числами строк; + - поиск — ровно семантика `matchesSearch`, включая перекрытие полей `contract_summary` (они не ищутся) и приведение чисел к тексту, как в JS; + - «Найдено N из M» — только по поиску; + - ввод в поиск, смена года или организаций сбрасывают колоночные фильтры. +10. Поиск без учёта регистра — **независимо от локали БД**: `LC_CTYPE` прода неизвестен. +11. **Пороги производительности не являются условием слияния.** PR вливается по зелёным тестам и доказанной корректности, цифры приводятся в PR, недобор уходит в follow-up. Жёсткие условия корректности: + - склейка страниц равна полному списку; + - варианты фильтров совпадают с вычисленными по полному ответу; + - поиск и фильтры совпадают с экраном до изменения; + - прежний режим байт-в-байт. ## Решения по устройству ### 1. Контракт `GET /api/v1/svod/2/{sheet}/{year}` -- **Прежние параметры** без изменений: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`. -- **Новые параметры, все необязательные:** +**Прежние параметры** не меняются: `org_ids`, `offset` (`ge=0`), `limit` (`ge=1`), `sort_by`, `sort_direction`. - | параметр | тип | ограничения | - |---|---|---| - | `view` | `Literal["full","compact"] \| None` | | - | `search` | `str \| None` | `max_length=200`, обрезается по краям | - | `smeta_type` | `Literal["АХР","КВ","Операц"] \| None` | | - | `smeta_direction` | `Literal["Поддержка","Развитие"] \| None` | | +**Новые параметры** (все необязательные): -- **Новый режим** включается, когда хотя бы один новый параметр не `None`, в том числе явный `view=full`. В новом режиме ответ — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). В прежнем режиме ответ тот же `BaseListResponse[SheetResponse]`, что и раньше. - - Аннотация возврата сейчас задаёт `response_model`, и модель отрезала бы `total`. Поэтому у обработчика нужно явно задать `response_model=Union[...]` или `response_model=None` и вернуть нужную модель по режиму. - - Обязателен тест «в прежнем режиме нет `total`». -- **Значения фильтров** — те же строки, что видит экран в `data.header` (`АХР`, `КВ`, `Операц`; `Поддержка`, `Развитие`), а не коды БД. Так варианты из facets подставляются в запрос без перевода. -- **Фильтр по организации** не получает нового параметра: экран сужает `org_ids` до id, выбранного в колонке «ССП», а сервер, как и раньше, пересекает его с разрешёнными (`_allowed_org_ids`). - - Альтернатива — `org_name` (точное совпадение по названию, как сейчас на клиенте) — отвергнута. Названия не уникальны по схеме (`org_unit.title` без `unique`), а фильтр по `bf.org_unit_id` уже есть в SQL. -- **`total`** — `count(*) OVER ()` в той же выборке, что нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, чтобы узнать `total`: это редкий путь, второго SQL на каждую страницу нет. +| параметр | тип | ограничение | +|---|---|---| +| `view` | `Literal["full","compact"] \| None` | | +| `search` | `str \| None` | `max_length=1000` | +| `smeta_type` | `str \| None` | `max_length=1000` | +| `smeta_direction` | `str \| None` | `max_length=1000` | +| `org_name` | `str \| None` | `max_length=1000` | + +Значения фильтров — свободные строки, а не перечисления: семантика `equals` (регистр и пробелы по краям не важны) принимает и «кв», и «КВ ». Неизвестное значение даёт 0 строк, как на экране. + +**Новый режим** включается, когда хотя бы один новый параметр не `None` (в том числе явный `view=full`). Ответ в нём — `BaseListResponse` плюс `total: int` (новая модель, например `Svod2PageResponse`). Прежний режим — тот же `BaseListResponse[SheetResponse]`. Аннотация возврата задаёт `response_model` и отрезала бы `total`, поэтому у обработчика нужно явно указать `response_model` (`Union` или `None`) и возвращать модель по режиму. Тест «в прежнем режиме нет `total`» обязателен. + +**Фильтр «ССП»** — по `org_name` с семантикой `equals`, как сейчас: одинаковые названия отбираются вместе. Сужение `org_ids` до id отвергнуто: оно изменило бы поведение при одинаковых названиях. `org_ids` остаётся выбором «ССП/РФ» с проверкой прав. + +**`total`** считается как `count(*) OVER ()` в выборке, которая нумерует строки, до `LIMIT`/`OFFSET`. Если страница пуста при `offset > 0`, сервис делает второй вызов с `offset=0, limit=1`, только чтобы узнать `total`. ### 2. Эндпоинт вариантов фильтров -`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=...` возвращает: +`GET /api/v1/svod/2/{sheet}/{year}/facets?org_ids=&search=&smeta_type=&smeta_direction=&org_name=` возвращает: ```json { @@ -115,395 +173,457 @@ "message": null, "result": { "total": 5563, - "smeta_type": ["АХР", "КВ", "Операц"], - "smeta_direction": ["Поддержка", "Развитие"], - "org": [{"id": 12, "name": "…"}] + "search_total": 300, + "smeta_type": [{"value": "АХР", "count": 120}], + "smeta_direction": [{"value": "Поддержка", "count": 80}], + "org_name": [{"value": "…", "count": 12}] } } ``` -- Обёртка — `BaseSingleResponse[Svod2Facets]`. -- Варианты считаются по базовому множеству (год + организации) **без** поиска и колоночных фильтров, `total` этого множества — это M в «Найдено N из M». -- Права — тот же `_allowed_org_ids`. Если пересечение пусто — нули и пустые списки. -- `sheet` проходит ту же валидацию, что и в основном эндпоинте (404 на неизвестный). +- `total` — по году и `org_ids` (M); +- `search_total` — плюс поиск (N); +- варианты каждой колонки — по году, `org_ids`, поиску и фильтрам двух других колонок, значения различаются точно, `null` не выдаётся; +- порядок значений в ответе не важен: экран сортирует их так же, как MRT (`localeCompare`). -### 3. SQL: новая миграция +Права те же — через `_allowed_org_ids`; при пустом пересечении все нули и пустые списки. Неизвестный `sheet` — 404. -**Файлы.** `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова. Номер — в разделе «Совместимость и порядок PR». +### 3. Семантика поиска и фильтров в SQL -**Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется (добавляется столбец), поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку. +**Регистр без зависимости от локали — `v3.svod2_casefold(text) RETURNS text`** (`LANGUAGE sql IMMUTABLE`): `translate(x, <прописные>, <строчные>)` по явной таблице. +- В таблице: + - ASCII `A-Z`; + - кириллица `А-Я` (U+0410–U+042F) → U+0430–U+044F; + - `Ѐ-Џ` (U+0400–U+040F, в том числе Ё, Є, І, Ї, Ў) → U+0450–U+045F; + - `Ґ` (U+0490) → U+0491; + - латиница-1 U+00C0–U+00DE, кроме `×` (U+00D7) → +0x20. +- `lower()` не используется: в `LC_CTYPE=C` он не трогает кириллицу, а в других локалях добавил бы преобразования, которых нет в таблице. +- Для символов из таблицы результат совпадает с JS `toLowerCase()`. Для прочих заглавных букв (греческие, расширенная латиница, особые случаи вроде `İ`) результат расходится, когда они есть в данных или запросе. В данных свода их не ожидается. +- Отвергнуты: + - `COLLATE "und-x-icu"` — PG может быть собран без ICU; + - `lower()` — зависит от `LC_CTYPE`. -**Общий отбор `v3.svod2_lines(...)`.** Новая функция: -- сигнатура `(p_year int, p_form_types varchar[], p_org_unit_ids int[], p_search text, p_smeta_type varchar, p_smeta_direction varchar)`; -- возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`; -- тело — `eligible_lines` из 0049 **без изменения условий отбора**, параметризованное списком типов форм (`{FORM_1,FORM_4}` для GO, `{FORM_2}` для RF), плюс три новых предиката. Они пишутся как `(p_x IS NULL OR …)`; -- `smeta_direction` — тот же `CASE bl.direction`, что в `header` (0049:197-201). +**Обрезка как в JS — `v3.svod2_js_trim(text)`**: `btrim` по явному набору символов ECMAScript `WhiteSpace` и `LineTerminator`: +- U+0009, U+000A, U+000B, U+000C, U+000D, U+0020, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF. -Функцию писать `LANGUAGE sql STABLE` **без** `SET search_path` (имена полностью квалифицированы `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивание не произошло (в EXPLAIN есть `Function Scan on svod2_lines`), это допустимо: множество строк ≤ десятков тысяч узких записей. Решение — `#3` в разделе решений без обсуждения. +Запрос поиска нормализуется на сервере в SQL так: `svod2_casefold(svod2_js_trim(p_search))`. Значения фильтров нормализуются как `svod2_js_trim(svod2_casefold(x))` — порядок как в MRT `equals`: сначала нижний регистр, потом обрезка. Пустой результат условия не накладывает. -**Поиск.** Предикат: +**Число в записи JS — `v3.svod2_js_number_text(double precision) RETURNS text`** (`IMMUTABLE`, `SET extra_float_digits = 1`). +- Цифры берутся из `x::text`: начиная с PG12, при `extra_float_digits > 0` это кратчайшая точная запись (Ryu), те же цифры, что у V8. `SET` в функции нужен, чтобы настройка сессии или сервера (`extra_float_digits <= 0` даёт `%.15g`) не влияла на результат. +- Из записи PG (`1234.5` или `1.5e+16`, `1e-05`) извлекаются цифры `s` (k штук) и десятичный показатель `n`. Затем применяется правило ECMAScript `Number::toString`: + - если `k ≤ n ≤ 21` — цифры и `n−k` нулей; + - если `0 < n ≤ 21` — цифры с точкой после `n`-й; + - если `−6 < n ≤ 0` — `0.`, `−n` нулей и цифры; + - иначе — экспоненциальная запись `d[.ddd]e±(n−1)`; + - отдельно знак минус и ноль `0`. +- Вход — `jsonb`-число из ответа, приведённое к `float8`. Это то же округление до двойной точности, что `json.loads` + `JSON.parse`. Целые больше 2^53 округляются так же, как в браузере. +- Корректность доказывается таблицей векторов. Набор значений (целые, дроби с хвостовыми нулями, `0.1+0.2`-подобные, 1e-7, 1e-6, 1e15, 1e21, большие целые, отрицательные) прогоняется через `String(Number(x))` в Node.js, результат коммитится как `api/tests/fixtures/js_number_text_vectors.json`. Интеграционный тест сверяет SQL-функцию со всеми векторами. + +**Текст значения для поиска** зависит от `jsonb_typeof`: + +| тип | текст | +|---|---| +| `string` | `#>> '{}'` | +| `number` | `svod2_js_number_text((v #>> '{}')::float8)` | +| `boolean` | `true`/`false` | +| `null` | пустая строка (никогда не совпадает с непустым запросом) | + +**Набор значений для поиска** строится из **тех же выражений, что и `data`** ответа, но только для трёх групп и только при непустом поиске: +- `header_j` — тот же `jsonb_build_object` заголовка, что в 0049:191-203 / 489-501; +- `cs_j = COALESCE(to_jsonb(cs) - 'line_id', <пустая секция>)`; +- `cd_j` — как в ответе листа: для RF с `expenses_qN = COALESCE(cd, ckk)` (решение контроллера `#16`). + +Объединение — `header_j || cs_j || cd_j`. Оператор `||` у `jsonb` перекрывает одноимённые ключи правым операндом, ровно как `{...a, ...b}` в JS, и перекрытые поля `contract_summary` пропадают так же. Предикат: ```sql -strpos(lower(concat_ws(E'\x1f', <поля>)), lower(p_search)) > 0 +EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e + WHERE strpos(svod2_casefold(<текст e.value>), q) > 0) ``` -- Поля: все ключи `header` (`section_code`, `item_id`, `num_group`, `name` = `COALESCE(bl.name, ei.name)`, `expense_item_id`, `justification`, `internal_order`, `vsp_id`, `project_id`, `direction`, `smeta_direction`, `source_form`, `source_id`, `source_org_unit_id`, `org_name`, `smeta_type`), все столбцы `contract_summary` и все столбцы `contract_detail`, кроме `line_id`. Таблицы уже присоединены в отборе как `presence_contract_summary` и `presence_contract_detail` (связь 1:1 по PK `line_id`, `DISTINCT` не размножается). -- Разделитель `\x1f` не даёт совпасть «через границу» двух значений; `strpos` не требует экранировать `%` и `_`. -- Числа приводятся `::text` (`numeric` → как в jsonb), даты — `::text` (ISO, как в JSON). -- Регистр кириллицы: `lower()` зависит от `LC_CTYPE` базы. На стенде (`en_US.utf8`) работает. Интеграционный тест с «аРЕНДА» доказывает это на конкретной базе, риск — в разделе рисков. +Соединения с `contract_summary`, `contract_detail` и `ckk` уже есть в отборе: `presence_*`, связь 1:1 по `line_id`. + +**Фильтры** — по столбцам отбора `smeta_type`, `smeta_direction`, `org_name`. Это те же выражения, что `header.smeta_type`, `header.smeta_direction`, `header.org_name` в ответе: условие `x IS NOT NULL AND svod2_js_trim(svod2_casefold(x)) = <нормализованный параметр>`. + +### 4. SQL: новая миграция + +Файлы `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`, `down_revision` — текущая голова; номер — в разделе «Совместимость». + +**Удаление старых сигнатур.** `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`. Тип результата меняется, поэтому `CREATE OR REPLACE` не пройдёт, а новая сигнатура без `DROP` дала бы перегрузку. + +**Общий отбор `v3.svod2_lines(p_sheet varchar, p_year int, p_org_unit_ids int[], p_search text)`.** +- Возвращает `line_id, form_type_code, form_id, org_unit_id, org_name, section_code, item_id, smeta_type, smeta_direction`. +- Тело — `eligible_lines` из 0049 **без изменения условий отбора**: + - типы форм выбираются по `p_sheet` (`GO` → FORM_1/FORM_4, `RF` → FORM_2); + - добавлены столбец `smeta_direction` (тот же `CASE bl.direction`, что в `header`) и предикат поиска из раздела 3 в виде `p_search IS NULL OR …` (`p_search` здесь уже нормализован). +- Колоночные фильтры сюда не входят: их по-разному применяют страница (все три) и варианты (все, кроме своего). +- Функцию писать `LANGUAGE sql STABLE` без `SET search_path` (имена с `v3.`), одним `SELECT`, чтобы планировщик мог её встроить. Если встраивания нет, это допустимо (`#3`). **Новые `v3.v_svod2_go` / `v3.v_svod2_rf`.** -- Аргументы — прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type varchar DEFAULT NULL`, `p_smeta_direction varchar DEFAULT NULL`, `p_compact boolean DEFAULT false`. -- Результат — прежние 8 столбцов + `total_count bigint`. Порядок прежних столбцов не меняется: репозиторий выбирает их по имени. -- `numbered_lines` строится поверх `v3.svod2_lines(...)` с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`. -- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF (например, `approved` у RF учитывает `ss.id`). +- Аргументы: прежние 6 + `p_search text DEFAULT NULL`, `p_smeta_type text DEFAULT NULL`, `p_smeta_direction text DEFAULT NULL`, `p_org_name text DEFAULT NULL`, `p_compact boolean DEFAULT false`. +- Результат: прежние 8 столбцов + `total_count bigint`. +- Сначала нормализация: обрезка и регистр, пустое значение → `NULL`. +- `numbered_lines` строится по `svod2_lines(...)` с тремя фильтрами, с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`. +- `page`, `booking_data` и сборка `data` копируются из 0049 **без изменений**, включая различия GO и RF. - `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный jsonb>) ELSE <полный jsonb> END`. -- Вызов прежними 6 аргументами (Excel RF, прежний режим API) работает за счёт значений по умолчанию и даёт те же строки, `sort_order` и `data`. +- Вызов прежними 6 аргументами (Excel RF, прежний режим API) даёт те же строки, `sort_order` и `data`. -**Проекция `v3.svod2_compact(data jsonb) RETURNS jsonb`** (`LANGUAGE sql IMMUTABLE`): `jsonb_strip_nulls(jsonb_build_object(...))` по путям из требования «Компактное представление строки». Это те же пути, что `export_keys` в `summary_repository.py:85-140`, плюс `line_id`, `header.source_form`, `header.source_id`, которые нужны для `getRowId` (`Svod2Page.jsx:397`). -- Альтернатива — массив значений без ключей, как `v_svod2_go_export.row_values`: меньше байтов, но фронту пришлось бы перекладывать строки в форму под `valueAt`, а проверка «компакт = проекция полного» стала бы сложнее. Отвергнута (`#4`). -- Альтернатива — отдельная сборка компактного `jsonb` прямо из таблиц: быстрее на страницу, но второй экземпляр сложной логики `booking_amount`/`plan_revision_*` (0049:248-284) с риском расхождения. Отвергнута: страница — это сотни строк, сборка полного `jsonb` для неё дешёвая. +**`v3.svod2_compact(data jsonb) RETURNS jsonb`** (`IMMUTABLE`) — `jsonb_strip_nulls(jsonb_build_object(...))` по путям из требования «Компактное представление строки». Это `export_keys` из `summary_repository.py:85-140` плюс `line_id`, `header.source_form`, `header.source_id` (для `getRowId`, `Svod2Page.jsx:397`). -**Варианты фильтров `v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[])`.** Возвращает строки `(kind varchar, value varchar, org_unit_id int, n bigint)`: -- `kind ∈ {'total','smeta_type','smeta_direction','org'}`; -- значения считаются по `v3.svod2_lines(p_year, <типы форм по листу>, p_org_unit_ids, NULL, NULL, NULL)`; -- `NULL`-значения `smeta_type` и `smeta_direction` не выдаются (`#7`); +**`v3.v_svod2_facets(p_sheet varchar, p_year int, p_org_unit_ids int[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text)`** возвращает строки `(kind varchar, value varchar, n bigint)`: +- `kind` принимает значения `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name`; +- одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска; +- три `GROUP BY` со своими условиями «поиск + фильтры двух других колонок» (`GROUP BY` по `varchar` с детерминированной сортировкой различает строки побайтно, как ключи `Map` в JS); +- два счётчика: `total` без условий и `search_total` только с поиском; - форму ответа API собирает репозиторий. -**PG15.** Не использовать возможности PG16+: `any_value`, конструкторы SQL/JSON (`JSON_OBJECT`, `JSON_ARRAY`…), `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` должны быть алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `concat_ws`, `strpos` есть в PG15. +**PG15.** Нельзя использовать возможности PG16+: `any_value`, конструкторы SQL/JSON, `IS JSON`, `JSON_TABLE`. У подзапросов в `FROM` нужны алиасы. `count(*) OVER ()`, `jsonb_strip_nulls`, `jsonb_each`, `translate`, `btrim(text, text)`, `strpos` есть в PG15. -**`v3.v_svod2_go_export` не трогается.** У неё остаётся своя копия `eligible_lines`. +**`v3.v_svod2_go_export` не трогается.** -### 4. Сервис и репозиторий +### 5. Сервис и репозиторий -- `SummaryRepository.get_svod2_rows` — без изменений сигнатуры и SQL: прежний режим и Excel RF. +- `SummaryRepository.get_svod2_rows` — без изменений (прежний режим и Excel RF). - Новые методы: - - `SummaryRepository.get_svod2_page(...)` — новые аргументы и `total_count`, возвращает `(rows, total)`; + - `SummaryRepository.get_svod2_page(...)` → `(rows, total)`; - `SummaryRepository.get_svod2_facets(...)`; - - в сервисе `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`. + - `SummaryService.get_svod2_page(...)` и `get_svod2_facets(...)` с тем же `_allowed_org_ids`. - Обработчик в новом режиме вызывает `get_svod2_page`, в прежнем — `get_svod2_rows`. -- Заголовок `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах, в лог добавляются новые параметры. На этом заголовке держится `api_split.py`. +- `X-DB-Time-Ms` и лог `db_time` ставятся в обоих режимах. +- Нормализацию (обрезку и регистр) делает SQL, а не Python: одна реализация на значения и запрос. -### 5. Фронт: окно страниц в той же таблице +### 6. Фронт: окно страниц в той же таблице -**Константы.** `PAGE_SIZE = 300`, окно — видимая страница ±1 (не больше 3 страниц в данных таблицы), кэш страниц — LRU не больше 5 страниц на вкладку (`#5`). Высота строки берётся из того же источника, что `estimateSize` виртуализатора: -- сейчас 37 px; -- после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()` из её `createFixedRowVirtualizerOptions`. +**Константы и окно.** +- `PAGE_SIZE = 300`; +- окно — видимая страница ±1, в данных таблицы не больше 3 страниц; +- кэш страниц — LRU не больше 5 страниц на вкладку (`#5`); +- в положении «в начале» окно — страницы 0 и 1, то есть страница 1 запрашивается сразу (`#15`). -Окно требует фиксированной высоты строки. Пока 1836 не влита, `measureElement` должен возвращать константу. +**Высота строки** берётся из того же источника, что `estimateSize` виртуализатора: сейчас 37 px, после rebase на AURORA-1836 — `ROW_HEIGHT_REM * getRootFontSizePx()`. Окно требует фиксированной высоты: пока 1836 не влита, `measureElement` возвращает константу. -**Данные таблицы.** Это не весь список, а срез окна `[windowStart, windowEnd)`: -- загруженные строки окна плюс заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна; -- позиция среза задаётся отступами виртуализатора строк `rowVirtualizerOptions = { ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`; -- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают `paddingStart`/`paddingEnd` (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` всегда стоит на `i * rowH`, а полоса прокрутки соответствует `total`; -- объектов строк TanStack (`createRow` создаёт десятки замыканий на строку) не больше размера окна, а не `total`; -- объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`) **не править** — это hunk AURORA-1836. Новые опции собираются в компоненте разворотом этого объекта. +**Данные таблицы — срез окна `[windowStart, windowEnd)`.** +- В срезе загруженные строки и заглушки `{ __placeholder: true, index }` на месте незагруженных страниц окна. +- Позиция задаётся отступами виртуализатора строк: `{ ...ROW_VIRTUALIZER_OPTIONS, paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH }`. +- MRT 3.2.1 ставит высоту `tbody` в `rowVirtualizer.getTotalSize()`, а строки сдвигает на `virtualRow.start`. Обе величины учитывают отступы (`@tanstack/virtual-core` 3.11.2), поэтому глобальная строка `i` стоит на `i * rowH`, а полоса прокрутки соответствует `total`. +- Объектов строк TanStack — не больше окна. +- Объект `ROW_VIRTUALIZER_OPTIONS` (`:26-31`, hunk AURORA-1836) **не править**. **Прокрутка.** -- `muiTableContainerProps.onScroll`, сглаженный через `requestAnimationFrame`, считает `firstVisible = floor(scrollTop / rowH)` и видимую страницу; -- когда видимая страница меняется, пересчитываются окно и данные, а недостающие страницы окна запрашиваются (соседние страницы окна — это и есть упреждающая загрузка); -- если видимая область целиком в отступе (быстрый перенос ползунка), виртуализатор рисует строки края среза вне экрана, а пользователь видит пустое место, пока окно не сдвинется на следующем кадре и не придут страницы. Это ожидаемо. +- `muiTableContainerProps.onScroll` через `requestAnimationFrame` считает видимую страницу по `scrollTop`. +- При смене страницы пересчитываются окно и данные, недостающие страницы окна запрашиваются. +- Программно положение прокрутки не меняется (решение 9): при смене вкладки, поиска или «Загрузить» окно считается от текущего `scrollTop`, который браузер ограничит при уменьшении высоты. Так же ведёт себя таблица сейчас. **Заглушки.** -- `muiTableBodyRowProps` для строки-заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`; -- правило в `SVOD2_TABLE_CONTAINER_SX` (`svod2Styles.js`) делает текст ячеек прозрачным и даёт ровный фон. Тогда `Cell`-функции колонок не меняются: на заглушке `valueAt` даёт `undefined` и `'—'`, но его не видно; -- `getRowId`: у заглушки `ph-${index}`, у данных — прежний ключ. +- `muiTableBodyRowProps` для заглушки ставит `className='svod2-placeholder'` и `data-svod2-placeholder`. При rebase объединяется со `style.height` из 1836. +- Правило в `SVOD2_TABLE_CONTAINER_SX` делает текст прозрачным, `Cell`-функции не меняются. +- `getRowId` заглушки — `ph-${index}`. -**Запросы.** В `web/src/api/summary.js`: -- `SummaryApi.getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection }, { signal })` — всегда `view=compact`, пустые параметры не передаются; -- `SummaryApi.getSvod2Facets(sheet, year, orgIds, { signal })`; -- `SummaryApi.get` остаётся для свода 1. +**Запросы** (`web/src/api/summary.js`): +- `getSvod2Page(sheet, year, { orgIds, offset, limit, search, smetaType, smetaDirection, orgName }, { signal })` — всегда `view=compact`, пустые параметры не передаются, `search` уходит как введён; +- `getSvod2Facets(sheet, year, { orgIds, search, smetaType, smetaDirection, orgName }, { signal })`; +- `get` остаётся для свода 1. -**Состояние вкладки** — хук загрузки страниц, например `useSvod2Pages.js` (без JSX). Держит: -- `queryKey` (лист, год, организации, поиск, три фильтра); -- `generation`; -- `total`; -- `facets`; -- `pages: Map` (LRU); -- `inflight: Map`; -- `scrollTop`. +**Состояние вкладки** — хук без JSX, например `useSvod2Pages.js`. Хранит `queryKey`, `generation`, `total`, `facets`, `pages` (LRU) и `inflight` (`AbortController`). Чистая логика вынесена в `web/src/pages/Svod2Page/svod2Paging.js` и покрыта `node --test`: +- окно; +- срез с заглушками; +- LRU; +- параметры запроса; +- преобразование `facets` в `Map` для MRT; +- условие показа «Найдено». -Чистая логика — окно, сборка среза с заглушками, LRU, параметры запроса из условий, «показывать ли Найдено» — выносится в `web/src/pages/Svod2Page/svod2Paging.js` и тестируется `node --test`. Ответ, пришедший с устаревшим `generation`, отбрасывается; при смене условий текущие запросы отменяются через `AbortController`. +Ответ устаревшего `generation` отбрасывается, текущие запросы отменяются. **Что вызывает запросы:** -- «Загрузить» или «Обновить»: новый `generation`, сброс страниц, прокрутка в начало, параллельно `facets` и страница 0; -- смена поиска (после debounce в `SvodFilters`) или колоночного фильтра при загруженных данных: новый `generation`, сброс страниц, прокрутка в начало, только страница 0. Варианты фильтров не перезапрашиваются: они не зависят от поиска и фильтров; -- смена года или организаций: как сейчас, подсказка «Нажмите «Загрузить»…» (`:448-455`); -- переключение вкладки: состояние другой вкладки (окно ≤ 5 страниц) сохраняется, возврат — без запроса. +- «Загрузить» или «Обновить» — новый `generation`, `loading = true` (как сейчас: индикатор, `Alert` при ошибке, «Найдено» скрыто), запросы `facets` и страниц окна; +- изменение поиска (после debounce) или колоночного фильтра при загруженных данных — новый `generation`, запросы `facets` и страниц окна. `loading` **не** выставляется, прежние строки остаются до ответа (`#20`); +- смена года или организаций — как сейчас, подсказка «Нажмите «Загрузить»…»; +- переключение вкладки — состояние вкладки сохраняется, запрашиваются только недостающие страницы окна. -**Колоночные фильтры.** -- `manualFiltering: true`, `enableFacetedValues: false`; -- у трёх колонок `filterSelectOptions` из `facets`: строки для `smeta_type`/`smeta_direction`, `{ label: name, value: String(id) }` для `org_name`. Опции подмешиваются в `columns` в `useMemo` поверх `getColumnConfig(sheet)`, кэш конфигурации колонок по листу остаётся; -- значение фильтра «ССП» → `org_ids = [id]` вместо организаций из выбора «ССП/РФ» (они уже ограничивают варианты); +**Колоночные фильтры и варианты.** Цель — сохранить рендер MRT без изменений: +- `manualFiltering: true`, `enableFacetedValues: true`; +- в опциях таблицы переопределяется `getFacetedUniqueValues: (table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_MAP`. Пользовательские опции перекрывают значения MRT по умолчанию (`useMRT_TableOptions`, `Object.assign(defaults, rest)`); +- MRT сам отбросит `null`, отсортирует `localeCompare` и покажет числа в скобках, как сейчас; +- `filterSelectOptions` **не** задавать: с ними MRT не показывает числа; +- значения фильтров уходят на сервер как есть (`smeta_type`, `smeta_direction`, `org_name`); - сброс колоночных фильтров при смене поиска, года или организаций (`:317-323`) сохраняется. -**«Найдено N из M».** Показывается, если данные загружены, загрузка не идёт, `total !== facets.total`: `Найдено ${total} из ${facets.total} строк` в `ru-RU`. +**«Найдено N из M»:** N = `facets.search_total`, M = `facets.total`, условие показа — прежнее: `hasCurrentData && !loading && N !== M`. **Ошибки.** -- первая страница или `facets` — прежний `Alert` с «Повторить» (`:456-468`); -- следующие страницы — `toast.error`, страница остаётся незагруженной и запрашивается снова, когда опять попадёт в окно. +- «Загрузить», «Обновить», поиск или фильтр — прежний `Alert` с «Повторить». +- Догрузка страницы при прокрутке — `toast.error`, страница остаётся незагруженной и запрашивается при повторном попадании в окно. -**Если `total` изменился между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез и отступы пересчитываются. Возможный сдвиг строк между страницами лечит «Обновить» (риск ниже). +**Изменение `total` между страницами** (кто-то правит формы): берётся последний `total` текущего `generation`, срез пересчитывается. -**`SvodContext` не меняется.** Свод 2 просто перестаёт звать `getSummary`/`invalidateSummary`, поэтому полные ответы в кэше не копятся. Кэш остаётся только у свода 1. +**`SvodContext` не меняется.** Свод 2 перестаёт звать `getSummary`/`invalidateSummary`. -**Запасной вариант (`#6`).** Если `paddingStart`/`paddingEnd` в MRT 3.2.1 ведут себя некорректно (проверка — первая задача фронт-PR на живом GO «все»), допускается массив длины `total` с общей заглушкой на незагруженных местах. Память тогда растёт как O(`total`) объектов строк TanStack, критерий памяти проверяется замером. Переход на запасной вариант записывается в этот файл, в раздел решений без обсуждения. +**Запасной вариант (`#6`).** Если отступы в MRT 3.2.1 работают некорректно (проверка — первая задача фазы 2), допускается массив длины `total` с общей заглушкой. Переход записывается в раздел решений без обсуждения. -### 6. Стенд `perf/` (PR 3) +### 7. Стенд `perf/` (фаза 3) -**`perf/e2e/scenarios/scroll.spec.ts`, только `kind === 'svod2'`.** Код должен работать и со сборкой «до»: заглушек и новых запросов там нет, метрики просто вырождаются. -- `waitRows` считает строки `tbody tr:not([data-svod2-placeholder])`. -- Новая метрика `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными. +**`scroll.spec.ts`**, только `kind === 'svod2'`. Код должен работать и со сборкой «до». +- `waitRows` считает `tbody tr:not([data-svod2-placeholder])`. +- `first_rows_ms` — от «Загрузить» до `MIN_ROWS` строк с данными. - Байты: - - `json_bytes_sheet` сохраняет смысл «максимум одного ответа» (у «после» это самая большая страница); - - новый `json_bytes_total` — сумма всех ответов по `respRe` за тест; - - новый `json_bytes_first` — сумма до появления первых строк. + - `json_bytes_sheet` — прежний смысл: максимум одного ответа; + - `json_bytes_total` — новое, сумма всех ответов; + - `json_bytes_first` — новое, сумма до первых строк. - `respRe` свода 2 расширяется на `/facets`. -- Новая метрика `placeholder_frame_share` — доля кадров прокрутки, в которых в видимой области контейнера есть заглушка (сэмплер на `requestAnimationFrame` в странице или колбэк шага в `lib/scroll.ts`). Рядом пишется `pages_requested`. -- Тест фильтра: после `fill('1')` ждать ответ запроса с `search=` (до 5 с; если запроса нет — это сборка «до» с клиентским поиском), затем `awaitStable`. Новая метрика `filter_result_ms` — от ввода до стабилизации. -- Подписи «ССП/РФ», «Все ССП/РФ», «Загрузить» не меняются (UX сохранён). Клики `:194-200` остаются. + `respRe` включает `/facets`. +- `placeholder_frame_share` и `pages_requested`. +- Тест фильтра: после `fill('1')` ждать ответ с `search=` (до 5 с; если запроса нет — это сборка «до»), затем `awaitStable`, метрика `filter_result_ms`. +- Клики `:194-200` и подписи не меняются. -**Новый `perf/e2e/scenarios/svod2-memory.spec.ts`.** -- Роль dfip, свежий контекст на повтор, `PERF_REPEATS` (по умолчанию 3). -- Шаги: - 1. `/svod/2`, GO «Все ССП/РФ» → «Загрузить» → строки → `heapUsed` (`perf/e2e/lib/cdp.ts:45`) → точка `go`; - 2. вкладка RF, «Все ССП/РФ» → «Загрузить» → строки → точка `rf`; - 3. по `PERF_SVOD2_MEM_SCROLL=1` (по умолчанию выключено, на масштабе 1 включить) — прокрутка RF до конца и обратно → точка `rf_scrolled`; - 4. вкладка GO → строки видны → точка `final`. -- Метрики: `heap_used_mb` по точкам, `nodes` (`domCounters`), `first_rows_ms` обеих загрузок. Запись `scenario: "svod2-memory"`, агрегирует `perfkit.aggregate`. +**Новый `svod2-memory.spec.ts`.** +- Роль dfip, свежий контекст на повтор, по умолчанию 3 повтора. +- Точки: + 1. `go` — GO «Все» → «Загрузить» → строки; + 2. `rf` — RF «Все» → «Загрузить» → строки; + 3. `rf_scrolled` — по `PERF_SVOD2_MEM_SCROLL=1`, на масштабе 1; + 4. `final` — вкладка GO. +- В каждой точке `heapUsed` после GC (`lib/cdp.ts:45`) и `domCounters`. -**`perf/analysis/api_split.py`.** -- Прежние строки (полные GO/RF) остаются: это проверка «прежний режим не медленнее». -- Добавляются: - - GO/RF `?view=full&offset=0&limit=300`; - - GO/RF `?view=compact&offset=0&limit=300`; - - GO/RF `/facets`; - - RF `?view=compact&offset=0&limit=300&search=1`. +**`api_split.py`.** Прежние строки остаются. Добавить: +- GO/RF `view=full&offset=0&limit=300`; +- GO/RF `view=compact&offset=0&limit=300`; +- `/facets`; +- RF `view=compact&…&search=1`. -**`perf/analysis/explain_manual.py`.** Новые записи: -- `svod2_go_page` — `SELECT … FROM v3.v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, true)`; +**`explain_manual.py`.** Новые записи: +- `svod2_go_page` (`v_svod2_go(<год>, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`); - `svod2_rf_page`; -- `svod2_rf_search` (`p_search='1'`); +- `svod2_rf_search` (`'1'`); - `svod2_go_facets`. -Варианты — по умолчанию и `.rerun_default`. Они показывают, сколько стоит отбор строк в каждой странице. +**`scale_compare.py`** (`:43-44`): раздельные ключи для полного ответа, страницы (`limit` в query) и `facets`. -**`perf/analysis/scale_compare.py`** (`:43-44`): разделить ключи на «GET свод 2 {sheet}» (прежний полный), «… страница» (в query есть `limit`) и «… facets». +**`perfkit.bench`.** Повторит каждый URL страницы. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`. -**`perfkit.bench`.** После 1843 сетевой лог содержит десятки URL страниц, и бенч повторит каждый. На масштабе 1 это около 50 URL × 20 повторов × ~0,3 с ≈ 5 мин; на масштабе 2 — в ~4 раза больше. Ограничивать через `--only` и `--repeats`, описать в `perf/README.md`. +**Сверка экрана и сервера на данных стенда** (только чтение), запускать перед замером «после»: +- `perf/analysis/svod2_paging_check.py` (Python) — склейка страниц, `total`, проекция compact; +- `perf/analysis/svod2_search_oracle.mjs` (Node) — **дословная копия** `matchesSearch`, фильтра MRT `equals` и подсчёта вариантов по правилам TanStack из кода «до»; применяется к прежнему полному ответу и сравнивается с ответами страниц и `facets` по набору поисков и фильтров. -**Новый `perf/analysis/svod2_paging_check.py`** — сверка на данных стенда, только чтение. Под dfip и admin для GO и RF: -- склейка всех компактных страниц совпадает по `line_id` и порядку с прежним полным ответом и равна его проекции; -- `total` равен длине полного; -- для поисков (`аренда`, `АРЕНДА`, `1`, подстрока названия организации) и для каждого значения трёх фильтров `total` и состав совпадают с фильтром по полному ответу на Python, повторяющим требование «Серверный поиск»; -- `/facets` совпадает с вычисленным по полному ответу. +## Найденные странности (не чиним в этой задаче) -Запускается перед замером «после». +- **Перекрытие полей поиска.** `matchesSearch` (`Svod2Page.jsx:284-288`) сливает `{...header, ...contract_summary, ...contract_detail}`. Поля `contract_summary.counterparty/reference/deadline/comment` перекрываются одноимёнными `contract_detail` и в поиске не участвуют, даже если `contract_detail` пуст. Скорее всего, это непреднамеренно. По решению 9 поведение сохраняется; кандидат в follow-up (статус `deferred`). +- **Поиск по техническим id.** `source_id`, `vsp_id`, `expense_item_id`, `source_org_unit_id` тоже ищутся, поэтому запрос «1» находит почти всё. Сохраняется; кандидат в follow-up (`deferred`). ## Совместимость и порядок PR -**Стек.** `test` ← … ← AURORA-1835 (PR #217) ← AURORA-1842 (PR #227) ← 1843: -- PR 1 `feat/236-…` — сервер; -- PR 2 — фронт; -- PR 3 — perf. +**Один план, три фазы и три ветки стеком под issue #236:** +- `feat/236-aurora-1843-svod2-server-paging` — PR 1, сервер, база — ветка 1842; +- `feat/236-aurora-1843-svod2-server-paging-web` — PR 2; +- `feat/236-aurora-1843-svod2-server-paging-perf` — PR 3. -Каждый PR зелёный сам по себе: -- после PR 1 старый фронт работает (прежний режим не изменился); -- PR 2 использует только API из PR 1; -- PR 3 меняет только `perf/`. +PR создаются по готовности, merge — по команде человека (`#14`). Каждый PR зелёный сам по себе: после PR 1 старый фронт работает. -**Номер миграции.** В ветке голова — `0049`. Номер `0050` уже заняли AURORA-1831 (`0050_project_technical_number_lock`), AURORA-1844 (`0050_upd_form_cells_full_rows`) и AURORA-1846 (`0050_form4_sheet_sum`), между собой они перенумеруются при слиянии. 1843 берёт рабочий номер `0053_svod2_server_paging` (после 0050–0052 этих трёх). При каждом rebase PR 1 нужно: -- проверить `alembic heads` (должна быть одна голова); +**Номер миграции.** Голова в ветке — `0049`. Номер `0050` заняли AURORA-1831, 1844 и 1846. Рабочий номер — `0053_svod2_server_paging`. При rebase PR 1: +- проверить `alembic heads` (должна остаться одна голова); - поставить номер «голова + 1»; - переименовать `.py` и `.sql`; -- поправить `revision`, `down_revision` и имя файла в `run_sql_file_migration`; -- перенацелить `test_summary2_related_records_sql.py` на новое имя. +- поправить `revision`, `down_revision` и имя в `run_sql_file_migration`; +- перенацелить `test_summary2_related_records_sql.py`. -Два head ломают `alembic upgrade head`, а при `DEBUG=false` — и старт приложения. +**AURORA-1836** правит `:26-31` (`2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3) и `muiTableBodyRowProps.style.height`. 1843 эти строки не трогает, опции собирает в компоненте. Высота строки окна — из того же источника. Если 1836 вольётся до замера, базу «до» переснять. -**AURORA-1836 (PR #222, от `test`).** Правит `Svod2Page.jsx`: -- `:26-31`: `ROW_HEIGHT` → `2.75rem`, `createFixedRowVirtualizerOptions`, overscan 3; -- `muiTableBodyRowProps.style.height`. +**AURORA-1848** переписывает тот же `get_summary2`. Кто вливается вторым, сохраняет оба режима. «Байт-в-байт» 1843 относится к состоянию до 1848. -1843 эти строки не трогает. Новые опции виртуализатора собираются в компоненте из `ROW_VIRTUALIZER_OPTIONS`, а у `muiTableBodyRowProps` 1843 добавляет класс и атрибут заглушки. Это функция-объединение: при rebase слить со `style.height` из 1836. +**AURORA-1917 (O-5).** Не вливать между «до» и «после» 1843. Если придёт раньше, правка отбора ложится в `v3.svod2_lines` и `v_svod2_go_export`. -Высота строки для окна должна идти из того же источника, что `estimateSize`. После rebase — из 1836, иначе отступы разойдутся с позициями строк: 37 против 44 px. - -Если 1836 вольётся в `test` раньше замера, базу «до» переснять на ребейзнутой вершине 1842. - -**AURORA-1848** (тяжёлые GET без pydantic, ещё не начата) переписывает тот же `get_summary2` на готовый JSON из SQL. Договорённость: -- кто вливается вторым, сохраняет оба режима 1843; -- для страниц (сотни строк) выигрыш 1848 мал; -- для прежнего режима (Excel RF не через HTTP, бенч, verify) 1848 вправе менять упаковку только с сохранением JSON по смыслу, как требует сама 1848. - -Требование «байт-в-байт» из 1843 относится к состоянию до 1848. - -**AURORA-1917 (O-5).** Исправление отбора RF по `is_ssp` уменьшит ответ RF и число строк. Его нельзя вливать между замером «до» и «после» 1843: либо до базового замера (тогда «до» переснимается), либо после «после». Если 1917 придёт раньше, правка отбора ляжет в `v3.svod2_lines` (одно место) и в `v_svod2_go_export`. - -**Выкатка и откат.** Миграция только заменяет функции и добавляет новые, данных не трогает. `downgrade()` в проекте пустые: откат PR 1 — новой миграцией, которая восстанавливает тела из 0049. Откат PR 2 безопасен сам по себе. +**Выкатка и откат.** Миграция только заменяет функции. Откат PR 1 — новой миграцией с телами из 0049. Откат PR 2 безопасен сам по себе. ## Тестирование (что доказывает корректность) -**PR 1, `api/tests/unit`:** -- валидация параметров через `TestClient` без БД (по образцу `test_query_validation.py`): 422 на `search` > 200, на неизвестные `smeta_type`/`smeta_direction`, на прежние невалидные `offset`/`limit`/`sort_*`; -- сервис: `_allowed_org_ids` применяется к страницам и вариантам; пустое пересечение даёт `[]`/0 без вызова SQL; второй вызов за `total` при пустой странице и `offset > 0` (моки репозитория, по образцу `test_export_service_query_params.py`); -- выбор режима: без новых параметров зовётся `get_svod2_rows`, и в ответе нет `total`; -- `test_summary2_related_records_sql.py` перенацелить на новую миграцию: отбор из `v3.svod2_lines` для GO (`FORM_1`, `FORM_4`) и RF (`FORM_2`), те же проверки, что для 0049. Добавить: поиск находит по `contract_summary`, `contract_detail` и `header`; разделитель не даёт совпасть через границу значений; фильтры `smeta_type`/`smeta_direction`. +**Окружение (`#18`).** +- Полный `pytest` идёт на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1843`. + - Создание: `docker exec perf-pg createdb -U perf dfip_1843`. + - Запуск: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 ../perf/.venv/bin/pytest tests --init_db`; перед каждым прогоном с `--init_db` БД пересоздаётся. + - БД `perf` не трогать. Пароли `OPENBAO__TEST_*` по умолчанию `admin123` (`tests/conftest.py`). +- Совместимость с PG15.12 — отдельный шаг плана «проверка контроллером», контейнер `dfip-pg15`, порт 55434. -**PR 1, `api/tests/integration`.** -- Фикстура (рядом с `client`) засевает в той же откатываемой сессии данные свода 2 за год, которого нет в `fixture.sql`: - - 3 организации (одна с не уникальным `title`); - - FORM_1, FORM_4 и FORM_2; - - все три `smeta_type`, оба направления и `direction = NULL`; - - `contract_summary` и `contract_detail` с кириллицей разного регистра; - - строки с `plan_q1 = 0`; - - пользователь-исполнитель с одной организацией. -- Оракул — прежний полный ответ: - - склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом; - - `search` (`аРЕНДА`, подстрока контрагента, цифра) и каждый фильтр совпадают с фильтром полного ответа на Python по требованию «Серверный поиск»; - - `view=compact` — проекция `view=full` без `null`, нули на месте; - - `/facets` совпадает с вычисленным по полному; - - исполнитель не видит чужую организацию ни в страницах, ни в вариантах; - - прежний режим — ключи как раньше и без `total`; существующие тесты `test_summary_api_smoke.py` проходят без правок. -- Весь набор `pytest` (зелёный только целиком): PG16 на стенде (отдельная БД `budget_test`, см. P4 «Стенд для тестов») плюс проверка контроллером на PG15.12. +**Фаза 1, `api/tests/unit`:** +- валидация (по образцу `test_query_validation.py`): 422 на `search`/фильтры длиннее 1000 и на прежние невалидные параметры; +- сервис: + - `_allowed_org_ids` применяется к страницам и вариантам; + - пустое пересечение даёт пустой ответ без SQL; + - второй вызов за `total` при пустой странице; + - выбор режима: без новых параметров зовётся `get_svod2_rows`, `total` нет; +- `test_summary2_related_records_sql.py` перенацелить на новую миграцию (`svod2_lines` для `GO`/`RF`, те же проверки отбора, что для 0049). -**PR 1, байт-в-байт на данных стенда.** До и после миграции снять `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, «все» и одна организация, `offset=0&limit=50`) и Excel RF. Сравнить sha256 распакованных тел; для xlsx — `openpyxl`-значения всех ячеек. +**Фаза 1, `api/tests/integration`.** Своя фикстура засевает данные свода 2 за год, которого нет в `fixture.sql`, в откатываемой сессии рядом с `client` (`#13`). Что засевается: +- 3 организации, две из них с одинаковым `title`; +- FORM_1, FORM_4, FORM_2; +- все `smeta_type`, оба направления и `NULL`; +- `contract_summary` со значением в перекрываемом поле при пустом `contract_detail`; +- кириллица разного регистра, `Ё`; +- дробные числа с хвостовыми нулями; +- RF-строка с `expenses_q1` только в ЦКК; +- исполнитель с одной организацией. -**PR 2, `node --test web/src/pages/Svod2Page/*.test.js`:** -- окно по `scrollTop`/`rowH`/`total` (край начала, края конца, `total < PAGE_SIZE`, `total = 0`); -- сборка среза: число заглушек на последней неполной странице, отступы `paddingStart`/`paddingEnd`, сумма = `total * rowH`; -- LRU не держит больше 5 страниц и не выбрасывает страницы окна; -- параметры запроса: пустые не передаются, фильтр «ССП» заменяет `org_ids`, `view=compact` всегда; -- отбрасывание ответа устаревшего `generation`; -- «показывать ли Найдено»; -- существующий `svod2Styles.test.js` зелёный. +Оракул — Python-порт правил экрана (`matchesSearch`, `equals`, варианты TanStack, `Number::toString`), применённый к прежнему полному ответу. Python-порт `Number::toString` сверяется с теми же векторами Node. Проверяется: +- склейка страниц `limit=2` равна полному списку, `total` верен на каждой странице и за концом; +- поиск (`аРЕНДА`, `ё`, перекрытое поле, число `1234.5` и `1234.50`, пробелы и NBSP по краям) и фильтры (`кв `, одинаковые названия) совпадают с оракулом; +- `facets` совпадает с оракулом для сочетаний поиска и фильтров; +- `svod2_casefold` и `svod2_js_trim` дают одинаковый результат при `LC_CTYPE` базы (тест вызывает их и сравнивает с ожидаемыми строками); +- `svod2_js_number_text` совпадает с векторами Node; +- compact — это проекция full; +- права; +- прежний режим без `total`, существующие смоук-тесты проходят без правок. -**PR 2, руками на стенде** (GO и RF, admin и dfip): -- «Все» и выбор организаций, «Загрузить»/«Обновить»; -- перенос ползунка в середину и в конец (номера строк по порядку, последняя строка у нижнего края); -- поиск, три фильтра, их сочетание, «Найдено N из M»; -- быстрый ввод с последующим выбором фильтра (без мигания старого результата); -- переключение вкладок, единицы, «Перейти к задачам»; -- горизонтальная прокрутка (закреплённые колонки, полосы, группы); -- ошибка страницы (остановить API во время прокрутки); -- скриншоты «до/после» первого экрана и трёх горизонтальных позиций. +**Фаза 1, байт-в-байт на стенде (`#17`).** До и после миграции: `GET /svod/2/{GO,RF}/{year}` без параметров (admin и dfip, все и одна организация, `offset=0&limit=50`) и Excel RF. Сравнение — sha256 распакованных тел; для xlsx — значения ячеек через `openpyxl`. -**PR 3.** -- Сценарии сначала прогнать с `PERF_REPEATS=1` на сборке «до» и «после», чтобы метрики писались, а `incomplete` не появлялось. -- `svod2_paging_check.py` — зелёный на масштабах 1 и 2. +**Фаза 2, `node --test web/src/pages/Svod2Page/*.test.js`:** +- окно и срез: края, неполная последняя страница, `total = 0`, сумма отступов; +- LRU; +- параметры запроса; +- `generation`; +- `facets` → `Map`; +- условие «Найдено» (только поиск); +- `svod2Styles.test.js` по-прежнему зелёный. + +**Фаза 2, руками на стенде** (выполняет контроллер, `#17`). Сравнение со сборкой «до» на тех же данных: +- GO и RF, admin и dfip, «Все» и выбор организаций; +- поиск, три фильтра и их сочетания, варианты фильтров с числами, «Найдено», сброс фильтров; +- вкладки и положение прокрутки; +- перенос ползунка в середину и в конец; +- быстрый ввод с последующим фильтром; +- единицы, «Перейти к задачам»; +- горизонтальная прокрутка; +- ошибка догрузки; +- скриншоты. + +**Фаза 3.** Пробный прогон сценариев с `PERF_REPEATS=1` на «до» и «после». `svod2_paging_check.py` и `svod2_search_oracle.mjs` должны быть зелёными на масштабах 1 и 2. ## Замер до/после -**Базы:** -- «до» — вершина AURORA-1842 (`399234a` или её ребейзнутая версия, если 1836 вольётся раньше); -- «после» — вершина PR 3. +Выполняет контроллер на стенде (`#17`). В плане это точка ожидания. PR открываются без цифр, цифры дописываются в PR по готовности. Reset стенда разрешён, но не во время чужих замеров. -Код сценариев в обоих прогонах один и тот же — из PR 3. Для «до» — временный worktree на вершине 1842 и `git checkout <ветка PR 3> -- perf/` в нём, без коммита. +**Базы:** +- «до» — вершина AURORA-1842 (или ребейзнутая, если 1836 вольётся раньше); +- «после» — вершина ветки PR 3. + +Код сценариев один (из PR 3): для «до» — временный worktree 1842 и `git checkout <ветка PR 3> -- perf/` без коммита. **Порядок на одних данных:** -1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` (~12 мин) → `verify`. Своды мерить до `grow`: `grow` доращивает AHR, который попадает в своды. -2. Скопировать `results/manifest-s1.json` в оба worktree; при желании снять `pg_dump -Fc`. -3. «До»: в worktree 1842 + perf из PR 3 поднять `api`, `web-build`, `web`. -4. Остановить `api`/`web`. -5. «После»: в worktree PR 3 `alembic upgrade head` поверх тех же данных, поднять `api`, `web-build`, `web`, прогнать `svod2_paging_check.py`, затем замеры. -6. То же для масштаба 2: `reset` + генерация ~45–70 мин. +1. `perf/stand.sh reset` → `perfkit.gen.run --scale 1` → `verify`. Своды мерить до `grow`. +2. Скопировать `results/manifest-s1.json` в оба worktree. +3. «До»: поднять `api`, `web-build`, `web` в worktree 1842 и снять замеры. +4. Остановить сервисы. +5. «После»: `alembic upgrade head` → `api`, `web-build`, `web` → `svod2_paging_check.py` и `svod2_search_oracle.mjs` → замеры. +6. То же на масштабе 2. -**Между «до» и «после» не вливать AURORA-1917 и AURORA-1836** (или переснять «до» после rebase). +Между «до» и «после» не вливать AURORA-1917 и AURORA-1836. -**Команды** (из `perf/e2e`, затем `cd .. && .venv/bin/python -m perfkit.aggregate results/`): +**Команды** (из `perf/e2e`, затем `perfkit.aggregate`): | что | команда | время на сторону (оценка) | |---|---|---| -| скролл + время до первых строк | `PERF_RUN_ID=1843--s PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` (RF не помещается в 5 000 шагов, REPORT раздел 6) | s1: «до» ~35 мин, «после» ~15 мин; s2: ×3–4 | +| скролл, время до первых строк | `PERF_RUN_ID=1843--s PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` | s1: «до» ~35 мин, «после» ~15 мин; s2 ×3–4 | | фильтр | тот же запуск с `--grep filter` | ~5 мин | -| память | `PERF_RUN_ID=1843-mem--s PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 только на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин «до» | +| память | `PERF_RUN_ID=1843-mem-<…>-s PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин | | байты и время API | `.venv/bin/python analysis/api_split.py --scale ` | ~3–5 мин | | планы SQL | `.venv/bin/python analysis/explain_manual.py --scale --out results/1843-explain-s` | ~3 мин | -**Пороги** — требования в `specs/`: +**Целевые значения** (`#10`, не условие слияния — решение 11): -| метрика | порог | +| метрика | цель | |---|---| -| `heap_used_mb` в точке `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 | -| `first_rows_ms` | ≤ 0,5 × «до», GO и RF, s1 и s2 | -| `bytes(compact)/bytes(full)` той же страницы | ≤ 0,40 | -| первая страница + facets | ≤ 5 % прежнего полного ответа | -| прежний полный запрос | медиана полного времени ≤ 1,10 × «до» | -| `main_thread_ms_per_step` (s1) | ≤ 1,10 × «до» (результат 1842); рядом `placeholder_frame_share` | +| память `final` | s1 ≤ 0,30 × «до»; s2 ≤ 1,25 × «после» s1 | +| `first_rows_ms` | ≤ 0,5 × «до» | +| compact / full | ≤ 0,40 | +| первая страница + facets | ≤ 5 % полного ответа | +| прежний запрос | ≤ 1,10 × «до» | +| шаг прокрутки | ≤ 1,10 × «до» | +| `placeholder_frame_share` | ≤ 5 % (если больше, цена шага «после» занижена) | -Если `placeholder_frame_share` > 5 %, цену шага «после» считать заниженной: заглушки дешевле строк. Разобрать, успевает ли догрузка (`explain_manual`: стоимость страницы), и при необходимости увеличить упреждение (окно ±2). - -Результаты записать в PR 3 и в отчёт задачи: таблица «до/после» по масштабам, ссылки на `results/…`. +Недобор → follow-up. ## Риски / компромиссы -- **[Каждая страница заново выполняет полный отбор строк — 13 LEFT JOIN + `DISTINCT` по году]** → Время страницы ≈ время отбора, а не сборки `jsonb`. `explain_manual` (`svod2_*_page`) показывает его отдельно. Упреждающая загрузка соседних страниц прячет задержку при обычной прокрутке. Если страница на масштабе 2 дольше ~1 с, следующий шаг — переписать отбор на `EXISTS`-полусоединения с тем же смыслом (сверка — тот же unit-тест отбора и `svod2_paging_check.py`) отдельным коммитом PR 1. -- **[Без новых параметров поиск и фильтры добавляют работу и замедляют прежний ответ]** → Предикаты вида `(p IS NULL OR …)`. Порог +10 % в `api_split` ловит регресс. -- **[`lower()` не переводит кириллицу при `LC_CTYPE=C` на проде]** → Интеграционный тест с «аРЕНДА» падает на такой базе. Обход — `lower(x COLLATE "und-x-icu")`, если PG собран с ICU. Узнать `LC_CTYPE` прод-базы до выкатки PR 1 (вопрос к эксплуатации, ответ не меняет дизайн). -- **[Данные меняются между запросами страниц: строки сдвигаются, на стыке страниц строка может повториться или пропасть]** → Свод — просмотр, «Обновить» даёт согласованный снимок. `total` обновляется по последнему ответу. Ключ строки (`source_form-source_id-line_id`) не даёт React смешать строки. -- **[Сдвиг среза отступами в MRT ведёт себя иначе, чем ожидается: прыжки, пустоты]** → Проверка в первой задаче PR 2. Запасной вариант `#6` — массив длины `total`. -- **[Окно опирается на фиксированную высоту строки, а 1836 меняет её с 37 px на 2,75 rem]** → Единый источник высоты. Тест суммы отступов. Чек-лист rebase выше. -- **[Замер шага «после» занижен заглушками]** → Метрика `placeholder_frame_share`, порог 5 % (см. «Замер»). -- **[Тест фильтра свода 2 «стабилизируется» до ответа сервера (первая мутация — индикатор загрузки)]** → Ожидание ответа с `search=` перед `awaitStable`. -- **[Варианты фильтров не учитывают поиск: можно выбрать значение, которое с поиском даёт 0 строк]** → Таблица покажет «Нет данных для выбранных параметров». Сейчас варианты учитывают поиск, это небольшое изменение UX (`#7`). -- **[Поиск по техническим id (`source_id`, `vsp_id`, `expense_item_id`) находит «лишнее», например по «1»]** → Так ищет и сейчас, поведение сохранено. Сужение полей поиска — отдельный вопрос бизнеса. -- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only`/`--repeats`, описание в README. +- **[Каждая страница и каждый запрос вариантов заново выполняет полный отбор строк: 13 LEFT JOIN + `DISTINCT`, плюс поиск по `jsonb_each`]** → `explain_manual` показывает цену отбора. Страница 1 и соседние страницы окна запрашиваются заранее. Если страница на масштабе 2 дольше ~1 с, отбор переписывается на `EXISTS` с тем же смыслом (сверка — те же тесты), отдельным коммитом фазы 1. +- **[Поиск строит три небольших `jsonb` на каждую отобранную строку]** → Только при непустом поиске. Цель «прежний режим ≤ 1,10» ловит регресс без поиска. +- **[`svod2_casefold` расходится с JS для заглавных букв вне таблицы]** → Таблица покрывает ASCII, кириллицу и латиницу-1. Остальное в данных свода не ожидается. Расширить таблицу — одна строка SQL. +- **[`Number::toString` в SQL расходится с V8]** → Векторный тест Node ↔ SQL. `SET extra_float_digits` внутри функции. +- **[Данные меняются между запросами страниц]** → «Обновить» даёт согласованный снимок, `total` берётся из последнего ответа. +- **[Отступы в MRT ведут себя не так, как ожидается]** → Проверка в первой задаче фазы 2, запасной вариант `#6`. +- **[1836 меняет высоту строки]** → Единый источник высоты, тест суммы отступов, чек-лист rebase. +- **[Цена шага «после» занижена заглушками]** → `placeholder_frame_share`. +- **[Тест фильтра свода 2 «стабилизируется» до ответа]** → Ожидание ответа с `search=`. +- **[Пока идёт запрос по поиску или фильтру, на экране прежние строки]** → Сейчас фильтрация мгновенная. Это допустимое отличие (spec: «Поведение для пользователя не меняется»). Индикатора нет, чтобы не менять вид (`#20`). +- **[`perfkit.bench` повторяет каждый URL страницы]** → `--only` и `--repeats`. ## Что оставлено исполнителю -- Имена моделей ответа, методов сервиса и репозитория, хука и файла чистой логики (предложенные выше — ориентир). -- Как именно вернуть `total` при пустой странице (второй вызов или `LEFT JOIN` к счётчику) — при условии одного SQL на обычную страницу. -- Оформление заглушки (фон, «бегущая» полоса или ровный цвет) — при прозрачном тексте и той же высоте. -- Где сэмплировать `placeholder_frame_share`: в пробе страницы или колбэком шага `scrollThrough`. -- Точные значения `PAGE_SIZE` (200–500) и окна (±1…±2), если замер покажет выигрыш. Изменение записать в решения без обсуждения. +- Имена моделей, методов, хука и файла чистой логики. +- Способ получить `total` при пустой странице, если обычная страница делает один SQL-запрос. +- Оформление заглушки при прозрачном тексте и той же высоте. +- Где сэмплировать `placeholder_frame_share`. +- `PAGE_SIZE` (200–500) и окно (±1…±2) по замеру. Изменение записать в решения без обсуждения. ## Decisions made without discussion -1. **2026-10-28 — новый режим API включается наличием любого из новых параметров, в том числе явного `view=full`. Поле `total` есть только в новом режиме.** - Обоснование: требование «без новых параметров ответ байт-в-байт прежний». `limit` — прежний параметр, привязать `total` к нему значило бы изменить прежний ответ. - Цена ошибки: если нужен `total` для прежнего полного вида, его даёт `view=full` — дёшево. -2. **2026-10-28 — фильтр «ССП» передаётся сужением `org_ids` до id организации, а не параметром `org_name`. Значения `smeta_type`/`smeta_direction` в API — подписи с экрана (`АХР`, `Поддержка`…), а не коды БД.** - Обоснование: `org_unit.title` не уникален, `org_ids` уже есть и проверяется на права. Подписи совпадают с `data.header` и вариантами — перевод не нужен. - Цена ошибки: при переименовании подписей в SQL нужно править валидацию API — одна строка. -3. **2026-10-28 — общий отбор строк выносится в `v3.svod2_lines` (одна копия условий для страниц GO/RF и вариантов), без `SET search_path`, чтобы функция могла встраиваться. `v_svod2_go_export` сохраняет свою копию.** +1. **2026-10-28 — новый режим API включается наличием любого из новых параметров (в том числе `view=full`); поле `total` есть только в нём.** + Обоснование: требование «без новых параметров байт-в-байт». `limit` — прежний параметр. + Цена ошибки: `total` для полного вида даёт `view=full` — дёшево. +2. **2026-10-28 — ~~фильтр «ССП» через `org_ids`, значения фильтров — перечисления~~. Отменено 28.10 решением человека 9: фильтр «ССП» — по `org_name` с семантикой MRT `equals`, значения фильтров — свободные строки.** +3. **2026-10-28 — общий отбор строк вынесен в `v3.svod2_lines` (одна копия условий для страниц и вариантов), без `SET search_path`, чтобы функция могла встраиваться.** Обоснование: четыре копии 90-строчного условия — источник расхождений между страницами и вариантами. - Цена ошибки: если встраивание не срабатывает и отбор заметно дорожает, вернуть копии в каждую функцию — час работы. Сравнение — `explain_manual`. -4. **2026-10-28 — компактный вид — та же вложенная форма `data`, только нужные пути, без ключей `null` (`jsonb_strip_nulls`). Строится проекцией полного `jsonb` в SQL (`v3.svod2_compact`), а не массивом значений и не отдельной сборкой из таблиц.** - Обоснование: фронт читает те же пути (`valueAt`); `null` и отсутствие ключа отображаются одинаково (`formatValue`, `textColumn`); у свода 2 нет WS-слияния, которое зависело бы от явных `null` (зависимость из 1848 — только у `RealtimeTable`). Проверка «компакт = проекция полного» тривиальна. Ожидаемо ~1–1,5 КБ на строку против ~5,5 КБ. - Цена ошибки: если 40 % не набирается, переход на массив значений меняет только `svod2_compact` и маппинг на фронте. -5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU не больше 5 страниц на вкладку.** - Обоснование: 300 строк — около 8 экранов и примерно 0,3–0,45 МБ компактного JSON. На шаг прокрутки 200 px граница страницы приходится примерно раз в 55 шагов, упреждения на соседнюю страницу хватает при ответе < 2–3 с. В памяти ≤ 1 500 строк данных и ≤ 900 строк в таблице. - Цена ошибки: подбор констант по замеру, правка двух чисел. -6. **2026-10-28 — окно реализуется срезом данных и отступами `paddingStart`/`paddingEnd` виртуализатора строк. Запасной вариант — массив длины `total` с заглушками.** - Обоснование: только срез даёт память O(окна) и для объектов строк TanStack, а не только для данных. Накопительная бесконечная прокрутка отвергнута: после прокрутки до конца в памяти снова весь ответ, что противоречит решению 28.10. - Цена ошибки: если срез не заработает с MRT 3.2.1, запасной вариант дороже по памяти (десятки МБ объектов строк на масштабе 2) — порог памяти может не выполниться. Тогда это вопрос к человеку. -7. **2026-10-28 — варианты фильтров считаются по году и организациям без учёта поиска и других фильтров. `NULL`-значения в варианты не попадают.** - Обоснование: один запрос вариантов на «Загрузить» вместо запроса на каждое изменение. M в «Найдено N из M» — тот же ответ. - Цена ошибки: если бизнесу нужны «перекрёстные» варианты, `facets` получает те же `search`/фильтры, а фронт перезапрашивает его при изменениях — полдня. -8. **2026-10-28 — поиск ищет и в тех полях `contract_summary` (`counterparty`, `reference`, `deadline`, `comment`), которые на клиенте сейчас фактически не ищутся: спред `{...contract_summary, ...contract_detail}` (`Svod2Page.jsx:284-288`) перекрывает их одноимёнными полями `contract_detail`. Числа сравниваются по тексту из БД (`1234.50`), а не по JS-записи (`1234.5`).** - Обоснование: «по тем же полям» понимается как «по тем же группам полей»; повторять перекрытие — значит повторять ошибку. - Цена ошибки: поиск находит немного больше строк, чем раньше; сузить — одна строка SQL. -9. **2026-10-28 — «Найдено N из M» считает поиск и колоночные фильтры вместе (раньше — только поиск).** - Обоснование: N приходит с сервера одним `total` по всем условиям, отдельно «только поиск» пришлось бы считать вторым запросом. - Цена ошибки: подпись иначе ведёт себя при одном колоночном фильтре без поиска. Вернуть прежнее — дополнительный `total` без фильтров. -10. **2026-10-28 — пороги успеха:** - - память s1 ≤ 30 % «до», s2 ≤ 1,25 × s1; - - время до первых строк ≤ 50 % «до»; - - компакт ≤ 40 % полного; - - первая страница + варианты ≤ 5 % полного ответа; - - прежний режим не медленнее +10 %; - - шаг прокрутки ≤ 1,10 × результата 1842; - - заглушки в кадре ≤ 5 %. + Цена ошибки: если отбор заметно дорожает, вернуть копии — час работы, сравнение через `explain_manual`. +4. **2026-10-28 — компактный вид — та же вложенная форма `data`, нужные пути, без `null`, проекцией полного `jsonb` в SQL (`v3.svod2_compact`).** + Обоснование: фронт читает те же пути; `null` и отсутствие ключа отображаются одинаково; WS-слияния у свода 2 нет; проверка «проекция полного» тривиальна. + Цена ошибки: если цель 40 % не набирается, переход на массив значений меняет `svod2_compact` и маппинг на фронте. +5. **2026-10-28 — `PAGE_SIZE = 300`, окно — видимая страница ±1, LRU 5 страниц.** + Обоснование: 300 строк — около 8 экранов, примерно 0,3–0,45 МБ компактного JSON; в памяти не больше 1 500 строк. + Цена ошибки: правка двух чисел по замеру. +6. **2026-10-28 — окно реализуется срезом данных и отступами виртуализатора; запасной вариант — массив длины `total` с заглушками.** + Обоснование: только срез даёт память O(окна) и для объектов строк TanStack. Накопительная бесконечная прокрутка отвергнута: в конце в памяти оказался бы весь ответ. + Цена ошибки: запасной вариант дороже по памяти — цель по памяти может не выполниться (follow-up). +7. **2026-10-28 — ~~варианты фильтров без учёта поиска и других фильтров~~. Отменено 28.10 решением человека 9: варианты — как в TanStack, с поиском и фильтрами других колонок, с числами.** +8. **2026-10-28 — ~~поиск ищет и в перекрытых полях `contract_summary`, числа — по тексту из БД~~. Отменено 28.10 решением человека 9: семантика `matchesSearch` повторяется точно, перекрытие описано как находка.** +9. **2026-10-28 — ~~«Найдено» считает поиск и фильтры вместе~~. Отменено 28.10 решением человека 9: только поиск (`search_total`).** +10. **2026-10-28 — целевые значения:** + - память s1 ≤ 30 %, s2 ≤ 1,25 × s1; + - первые строки ≤ 50 %; + - compact ≤ 40 %; + - первая страница + варианты ≤ 5 %; + - прежний режим ≤ +10 %; + - шаг прокрутки ≤ +10 %; + - заглушки ≤ 5 %. - Обоснование: оценки по REPORT (М2, М9, М10). Перечисленные пороги консервативны, ожидаемый эффект больше. 10 % — шум при n=2. - Цена ошибки: порог окажется недостижим или слишком слаб — пересмотр по фактическим цифрам с человеком, код не меняется. -11. **2026-10-28 — стенд: код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`. `json_bytes_sheet` сохраняет смысл «максимум одного ответа», суммарные байты — новые метрики.** - Обоснование: сопоставимость метрик и прежних отчётов. - Цена ошибки: при расхождении кода сценариев «до» нужно переснять — около часа на масштабе 1. -12. **2026-10-28 — рабочий номер миграции `0053`; окончательный — «голова `test` + 1» при rebase.** - Обоснование: 0050 занят тремя PR, их порядок неизвестен. - Цена ошибки: переименование двух файлов и правка тестов. -13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2 в откатываемой сессии, а не полагаются на `fixture.sql`.** - Обоснование: в фикстуре нет ни одной строки, проходящей отбор свода 2 за 2026 год. Тесты со `skip` при отсутствии данных ничего не доказывали бы. + Решением человека 11 это цели, а не условия слияния. + Обоснование: оценки по REPORT (М2, М9, М10). + Цена ошибки: пересмотр по фактическим цифрам, код не меняется. +11. **2026-10-28 — код сценариев «до» и «после» один (из PR 3), «до» снимается во временном worktree 1842 с подменой `perf/`; `json_bytes_sheet` сохраняет смысл, суммарные байты — новые метрики.** + Обоснование: сопоставимость. + Цена ошибки: переснять «до» — около часа на масштабе 1. +12. **2026-10-28 — рабочий номер миграции `0053`, окончательный — «голова + 1» при rebase.** + Обоснование: 0050 занят тремя PR. + Цена ошибки: переименование двух файлов. +13. **2026-10-28 — интеграционные тесты засевают свои данные свода 2.** + Обоснование: в `fixture.sql` нет строк, проходящих отбор свода 2 за 2026 год. Цена ошибки: нет. +14. **2026-10-28 (контроллер) — один план на три PR (три фазы); ветки `feat/236-aurora-1843-svod2-server-paging` (PR 1, база — ветка 1842), `…-web` (PR 2), `…-perf` (PR 3), стеком, все под issue #236. Остановки для человека перед созданием PR нет, PR создаются по готовности, merge — по команде человека.** + Цена ошибки: при замечаниях к PR 1 ветки PR 2 и PR 3 ребейзятся каскадом — до часа на rebase. +15. **2026-10-28 (контроллер) — сразу после страницы 0 запрашивается страница 1, чтобы при подходе к строке 300 не было заглушек (окно ±1 в начале даёт ровно это).** + Цена ошибки: лишний запрос, если пользователь не прокручивает, — ~0,3–0,5 МБ и одно выполнение отбора. +16. **2026-10-28 (контроллер) — поиск по `contract_detail.expenses_q1..4` на листе RF — по значениям ответа (`COALESCE(cd, ckk)`), как их видит экран.** + Цена ошибки: если выбрать неверно, строки с суммой только в ЦКК не находились бы, хотя видны на экране. Это закрыто тестом с RF-строкой «только ЦКК». +17. **2026-10-28 (контроллер) — замеры, ручные проверки и байт-в-байт сверку на стенде выполняет контроллер; reset стенда разрешён (стенд наш), но не во время чужих замеров. В плане — точка ожидания, PR открываются без цифр.** + Цена ошибки: цифры появляются в PR позже кода; на слияние не влияет (решение 11). +18. **2026-10-28 (контроллер) — тестовое окружение:** + - полный `pytest` на PG16 (`perf-pg`, порт 55432, БД `dfip_1843`, БД `perf` не трогать, пароли `admin123`); + - PG15.12 проверяет контроллер (`dfip-pg15`, порт 55434) отдельным шагом плана. + + Цена ошибки: несовместимость с PG15 выявится на шаге контроллера, а не у исполнителя — одна итерация правки. +19. **2026-10-28 — регистр без зависимости от локали — `translate` по явной таблице (ASCII, кириллица U+0400–U+042F и U+0490, латиница-1), без `lower()` и без ICU.** + Обоснование: решение человека 10. `lower()` зависит от `LC_CTYPE`, ICU может отсутствовать в сборке PG. + Цена ошибки: расхождение с JS для заглавных букв вне таблицы (греческие, расширенная латиница). В данных не ожидается, расширение таблицы — одна строка. +20. **2026-10-28 — при поиске и колоночном фильтре до прихода ответа показываются прежние строки, без индикатора загрузки. Индикатор и `Alert` остаются только у «Загрузить»/«Обновить».** + Обоснование: сейчас поиск мгновенный и индикатора нет; это ближайшее к прежнему поведение. Так «Найдено» не мигает. + Цена ошибки: при медленном сервере пользователь не видит, что запрос идёт. Добавить тонкую полосу прогресса — полчаса. +21. **2026-10-28 — значения для поиска строятся теми же выражениями, что `data` ответа (`header`, `contract_summary`, `contract_detail` листа), объединяются `jsonb ||` (перекрытие как у JS-спреда), числа приводятся к тексту функцией `svod2_js_number_text` (правила `Number::toString` поверх кратчайшей записи `float8`), обрезка — по набору пробельных символов ECMAScript.** + Обоснование: решение человека 9 — точное повторение `matchesSearch`. + Цена ошибки: если есть расхождение, оно ловится оракулом Node на данных стенда до слияния. +22. **2026-10-28 — ограничение длины `search` и значений фильтров — 1000 символов (422).** + Обоснование: защита от очень длинных URL. На экране ограничения нет, но поиск длиннее 1000 символов нереалистичен. + Цена ошибки: пользователь с таким запросом увидит ошибку вместо пустого результата. Снять ограничение — одна строка. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/proposal.md b/openspec/changes/aurora-1843-svod2-server-paging/proposal.md index f031d6a..2091d66 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/proposal.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/proposal.md @@ -24,13 +24,13 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 ## What Changes - **API `GET /api/v1/svod/2/{sheet}/{year}`.** У эндпоинта уже есть `org_ids`, `offset`, `limit`, `sort_by` и `sort_direction`. Добавляются необязательные параметры: - - `search` — поиск подстроки без учёта регистра по тем же полям, что ищет экран сейчас; - - `smeta_type` и `smeta_direction` — фильтры по виду сметы и направлению; + - `search` — поиск с точно той же семантикой, что у экрана сейчас (`matchesSearch`: те же поля с тем же перекрытием, числа в записи JavaScript, регистр не зависит от локали БД); + - `smeta_type`, `smeta_direction` и `org_name` — колоночные фильтры с семантикой фильтра таблицы экрана (`equals`: без учёта регистра и пробелов по краям); - `view=full|compact` — `compact` отдаёт только поля, которые экран показывает. - Если в запросе есть хотя бы один новый параметр, в ответе появляется поле `total` — число строк по всем условиям запроса без учёта `offset`/`limit`. `count`, как и раньше, равен длине `result`. Фильтр по организации — это сужение `org_ids` (параметр уже есть), с прежней проверкой прав. + Если в запросе есть хотя бы один новый параметр, в ответе появляется поле `total` — число строк по всем условиям запроса без учёта `offset`/`limit`. `count`, как и раньше, равен длине `result`. `org_ids` по-прежнему задаёт выбор ССП/РФ и проверяется на права. - **Без новых параметров ответ байт-в-байт прежний.** Это нужно Excel-выгрузке RF, интеграционным тестам, `perfkit.bench` и `perfkit.gen.verify`. -- **Новый эндпоинт `GET /api/v1/svod/2/{sheet}/{year}/facets`.** Отдаёт общее число строк и варианты трёх колоночных фильтров («Вид сметы», «Направление», «ССП») для выбранного года и организаций. +- **Новый эндпоинт `GET /api/v1/svod/2/{sheet}/{year}/facets`.** Отдаёт общее число строк, число строк по поиску («Найдено N из M») и варианты трёх колоночных фильтров («Вид сметы», «Направление», «ССП») с числами строк. Варианты считаются как сейчас на экране: с учётом поиска и фильтров двух других колонок. - **SQL.** Новая миграция: - `DROP` прежних `v3.v_svod2_go` и `v3.v_svod2_rf` (6 аргументов) и `CREATE` с новой сигнатурой, без перегрузок; - общий отбор строк свода 2 выносится в одну функцию, её используют и страницы, и варианты фильтров; @@ -38,17 +38,19 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 Всё совместимо с PostgreSQL 15. Отбор строк (какие строки попадают в свод) не меняется. - **Экран свода 2.** + - Для пользователя всё остаётся таким же (решение 28.10): строки, поиск, варианты фильтров с числами, «Найдено N из M», сброс фильтров, вкладки, прокрутка. Отличаются только ожидание ответа сервера и заглушки при быстрой прокрутке. - Таблица и колонки остаются прежними. - - После «Загрузить» приходит первая страница строк, остальные догружаются при прокрутке. + - После «Загрузить» приходят первые две страницы строк (вторая — заранее), остальные догружаются при прокрутке. - В памяти держится окно из нескольких страниц вокруг видимой области, а не весь ответ. Полоса прокрутки соответствует полному числу строк. - Поиск (debounce 300 мс уже есть) и три колоночных фильтра выполняются на сервере, варианты фильтров приходят с сервера. - - «Найдено N из M» строится из `total` и считает и поиск, и колоночные фильтры. + - «Найдено N из M» считает, как и сейчас, только поиск. - Свод 2 больше не кладёт ответы в кэш `SvodContext`. Кэш остаётся только у свода 1, его поведение не меняется. - **Стенд замеров `perf/`.** - `scroll.spec.ts`: свод 2 учитывает строки-заглушки, добавлены метрики времени до первых строк и байтов, фильтр ждёт ответа сервера. - Новый сценарий `svod2-memory.spec.ts` (GO «все» → RF «все» → GO, куча после GC). - В `api_split.py`, `explain_manual.py` и `scale_compare.py` добавлены страницы, компактный вид и варианты фильтров. - - Скрипт сверки серверной выдачи со старым полным ответом на данных стенда. + - Скрипты сверки серверной выдачи со старым полным ответом на данных стенда, в том числе оракул на Node.js с дословной копией `matchesSearch` и правил фильтров и вариантов таблицы. + - Цифры замеров — цели, а не условие слияния: недобор уходит в follow-up (решение 28.10). Условие слияния — доказанная корректность. - **Три PR в стеке:** 1. сервер — SQL, API, тесты; 2. фронт — догрузка, поиск, фильтры; @@ -60,13 +62,14 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 - Какие строки попадают в свод 2. Отбор по наличию данных — миграция 0049. Отбор RF по типу формы — баг O-5, он решается отдельно в AURORA-1917. - Свод 1, сводные проектов, `SvodFilters` (общий для сводов 1 и 2), кэш `SvodContext` для свода 1. - Сортировка свода 2: на экране она выключена (`enableSorting: false`), на сервере остаётся по умолчанию `org_name asc`. +- Семантика поиска, колоночных фильтров, вариантов фильтров и «Найдено N из M», включая найденную странность поиска (поля `contract_summary`, одноимённые полям `contract_detail`, не ищутся) — описана в design.md как находка, не исправляется. - Набор, порядок, ширины и форматирование колонок, единицы (руб./тыс./млн), закреплённые колонки, «Перейти к задачам». Подписи «Все ССП/РФ», «Загрузить» и «Обновить» и порядок действий пользователя (выбрать организации → «Загрузить») тоже прежние. ## Capabilities ### New Capabilities -- `summary2-api`: контракт API свода 2. Постраничная выдача с `total`, серверный поиск и фильтры, компактное представление строки, варианты фильтров, права доступа, совместимость прежнего ответа, критерии размера ответа. -- `summary2-screen`: поведение экрана свода 2. Догрузка строк страницами при прокрутке, ограниченное окно строк в памяти, поиск и фильтры через сервер, «Найдено N из M», ошибки. Критерии производительности: память, время до первых строк, шаг скролла. +- `summary2-api`: контракт API свода 2. Постраничная выдача с `total`, серверные поиск, фильтры и варианты фильтров с семантикой экрана, компактное представление строки, права доступа, совместимость прежнего ответа, замер размера ответа. +- `summary2-screen`: поведение экрана свода 2. Неизменное для пользователя поведение, догрузка строк страницами при прокрутке, ограниченное окно строк в памяти, поиск и фильтры через сервер, «Найдено N из M», ошибки. Замеры производительности: память, время до первых строк, шаг скролла. ### Modified Capabilities Нет: `openspec/specs/` пока пуст. @@ -81,6 +84,7 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 - новая миграция `api/alembic/versions/00NN_svod2_server_paging.py` + `sql/00NN_svod2_server_paging.sql`. Номер выбирается при rebase, см. design.md. - **Тесты бэка:** - `api/tests/integration/test_summary_api_smoke.py` и новые интеграционные тесты со своими данными свода 2; + - векторы `api/tests/fixtures/js_number_text_vectors.json` (запись чисел в JavaScript, сгенерированы Node.js); - `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить на новую миграцию); - unit-тесты сервиса и валидации параметров. - **Фронт:** @@ -92,7 +96,7 @@ Gitea issue #236, Jira AURORA-1843 (эпик AURORA-1834). Стек: AURORA-1835 - `perf/e2e/scenarios/scroll.spec.ts`; - новый `perf/e2e/scenarios/svod2-memory.spec.ts`; - `perf/analysis/api_split.py`, `perf/analysis/explain_manual.py`, `perf/analysis/scale_compare.py`; - - новый `perf/analysis/svod2_paging_check.py`; + - новые `perf/analysis/svod2_paging_check.py` и `perf/analysis/svod2_search_oracle.mjs`; - `perf/README.md`. - **Связанные задачи:** - **AURORA-1842** (PR #227, база стека) — виртуализация колонок и CSS-стили свода 2; от неё берётся результат шага скролла, который нельзя ухудшить. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md index 5527a60..2ddabe0 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md @@ -1,14 +1,14 @@ ## Purpose -Контракт API свода 2 (`/api/v1/svod/2/...`): постраничная выдача строк листа GO или RF с общим числом строк, серверные поиск и фильтры, компактное представление строки для экрана, варианты колоночных фильтров и совместимость прежнего ответа для выгрузки, тестов и стенда замеров. +Контракт 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 выдавать тот же файл, что до изменения. +Запрос `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}` без параметров до и после изменения +- **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 @@ -20,58 +20,91 @@ - **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` — верным. +Если в запросе есть хотя бы один из параметров `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` в новом режиме +- **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`. +### 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` SHALL NOT сужать выборку. `search` длиннее 200 символов SHALL отклоняться с 422. Поиск SHALL сочетаться с остальными условиями по «И». +Поиск сочетается с остальными условиями по «И». `search` длиннее 1000 символов SHALL отклоняться с 422. -#### Scenario: Поиск без учёта регистра по кириллице -- **WHEN** в данных есть строка с `header.name = «Аренда помещения»` и выполняется запрос с `search=аРЕНДА` -- **THEN** строка есть в выдаче, а `total` равен числу строк, у которых хотя бы одно поле из перечисленных групп содержит «аренда» без учёта регистра +#### 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=%20%20` (два пробела) в новом режиме +- **WHEN** в новом режиме передан `search` из одних пробельных символов - **THEN** `total` равен `total` того же запроса без `search` -#### Scenario: Слишком длинный поиск -- **WHEN** длина `search` больше 200 символов -- **THEN** ответ — 422 +#### Scenario: Сверка с поиском экрана на данных стенда +- **WHEN** для GO и RF масштаба 1 по набору запросов (`аренда`, `АРЕНДА`, `1`, `,`, подстрока названия организации, подстрока контрагента, дробное число из данных) серверная выдача сравнивается с функцией `matchesSearch` экрана до изменения, применённой в Node.js к прежнему полному ответу +- **THEN** множества и порядок `line_id` совпадают для каждого запроса -### Requirement: Фильтры по виду сметы, направлению и организации -`smeta_type` SHALL принимать одно из значений `АХР`, `КВ`, `Операц` и оставлять строки с точно таким `data.header.smeta_type`. `smeta_direction` SHALL принимать `Поддержка` или `Развитие` и оставлять строки с точно таким `data.header.smeta_direction`. Другие значения SHALL отклоняться с 422. Фильтр по организации задаётся параметром `org_ids` (список id) и SHALL пересекаться с организациями, разрешёнными пользователю, как и раньше. Все условия сочетаются по «И». +### 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** запрос с `search=X`, `smeta_direction=Развитие` и `org_ids=` -- **THEN** выдача — пересечение трёх условий, `total` равен числу строк полного ответа для `org_ids=`, которые удовлетворяют поиску и направлению +#### Scenario: Организации с одинаковым названием +- **WHEN** у двух организаций одинаковое название и запрос передаёт `org_name` с этим названием +- **THEN** в выдаче строки обеих организаций, как при выборе этого значения в фильтре «ССП» на экране до изменения -#### Scenario: Недопустимое значение фильтра -- **WHEN** передан `smeta_type=AHR` или `smeta_direction=Support` -- **THEN** ответ — 422 +#### 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 содержать только поля ниже, со значениями, равными значениям тех же путей в полном представлении: @@ -101,36 +134,24 @@ - **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, списки вариантов пусты. +Для не-администратора выдача страниц, `total` и варианты фильтров SHALL строиться только по организациям, привязанным к пользователю. Запрошенные `org_ids` вне этого множества SHALL отбрасываться, как в прежнем режиме. Если пересечение пусто, ответ SHALL быть пустым: `result` пустой, `total` = 0, `search_total` = 0, списки вариантов пусты. -#### Scenario: Чужая организация в фильтре +#### Scenario: Чужая организация в выборке - **WHEN** исполнитель, привязанный к организации A, запрашивает страницу и `/facets` с `org_ids=` -- **THEN** `result` пустой, `total` = 0, `org` в вариантах пуст +- **THEN** `result` пустой, `total` = 0, варианты пусты -### Requirement: Размер и стоимость ответа -На данных стенда масштаба 1: -- распакованный размер страницы `view=compact` SHALL быть не больше 40 % размера той же страницы `view=full`; -- распакованный размер первой страницы (`limit` = размер страницы экрана), который экран получает до показа первых строк, SHALL быть не больше 5 % прежнего полного ответа того же листа. +### Requirement: Замер размера и стоимости ответа +Для GO и RF на масштабах 1 и 2 SHALL быть сняты и приведены в PR цифры «до/после». Цифры условием слияния не являются: недобор оформляется follow-up задачей. Что снимается: +- распакованный размер страницы `view=compact` и той же страницы `view=full`; +- распакованный размер первой страницы и вариантов против прежнего полного ответа; +- медиана времени прежнего полного запроса. -Время ответа запроса в прежнем режиме (без новых параметров) SHALL ухудшиться не больше чем на 10 % по медиане. +Целевые значения: +- `compact` не больше 40 % `full`; +- первая страница вместе с вариантами не больше 5 % прежнего полного ответа; +- время прежнего запроса не больше 1,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 от замера «до» +- **WHEN** `perf/analysis/api_split.py --scale ` выполняет для GO и RF полный прежний запрос, страницу `offset=0&limit=<размер страницы>` с `view=full` и с `view=compact` и запрос `/facets` +- **THEN** в отчёте PR есть отношения размеров и времён к целевым значениям, а каждый недобор записан как follow-up diff --git a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md index 28156f1..12babf3 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-screen/spec.md @@ -1,15 +1,36 @@ ## Purpose -Поведение экрана свода 2 (`/svod/2`, вкладки «ССП» и «РФ»): загрузка строк страницами при прокрутке с ограниченным объёмом данных в памяти, поиск и колоночные фильтры через сервер, счётчик «Найдено N из M» и критерии производительности экрана (память, время до первых строк, шаг прокрутки). +Поведение экрана свода 2 (`/svod/2`, вкладки «ССП» и «РФ») при загрузке строк страницами: для пользователя всё остаётся таким же, как до изменения (поиск, колоночные фильтры, варианты фильтров, «Найдено N из M», вкладки, прокрутка), а в памяти держится ограниченное окно строк. Плюс замеры производительности экрана: память, время до первых строк, шаг прокрутки. ## ADDED Requirements +### Requirement: Поведение для пользователя не меняется +Всё, что пользователь видит и делает на экране свода 2, SHALL остаться таким же, как до изменения, при тех же данных: +- набор и порядок строк при любых сочетаниях года, ССП/РФ, поиска и колоночных фильтров; +- варианты колоночных фильтров и числа в скобках у вариантов; +- подпись «Найдено N из M строк» и момент её показа; +- сброс колоночных фильтров; +- поведение вкладок, «Загрузить», «Обновить», «Перейти к задачам», единиц; +- колонки, форматирование и оформление. + +Допустимые отличия: +- время ожидания ответа сервера после ввода поиска или выбора фильтра; +- пустые строки-заглушки на месте ещё не пришедших строк при быстрой прокрутке. + +#### Scenario: Визуальный паритет +- **WHEN** на одних данных открыть GO «все» до и после изменения и сравнить первые экраны и три позиции горизонтальной прокрутки +- **THEN** значения ячеек, прочерки и оформление совпадают + +#### Scenario: Сброс колоночных фильтров +- **WHEN** задан колоночный фильтр, и пользователь меняет строку поиска, год или выбор ССП/РФ +- **THEN** колоночные фильтры вкладки сбрасываются, как до изменения + ### Requirement: Загрузка строк страницами при прокрутке -После нажатия «Загрузить» или «Обновить» экран SHALL запрашивать с сервера только страницу строк с начала списка и варианты колоночных фильтров. Полная длина полосы прокрутки SHALL соответствовать общему числу строк по текущим условиям. Строки, которые ещё не пришли, SHALL показываться пустыми строками-заглушками той же высоты: без значений и без прочерков, которыми помечаются пустые данные. Когда видимая область доходит до незагруженных строк, экран SHALL их запросить и заменить заглушки данными, без сдвига прокрутки. В памяти страницы SHALL держаться строки не более чем фиксированного числа страниц вокруг видимой области, независимо от общего числа строк. Порядок действий пользователя (выбор года и ССП/РФ, пункт «Все ССП/РФ», кнопка «Загрузить») и подписи не меняются. +После нажатия «Загрузить» или «Обновить» экран SHALL запрашивать с сервера страницы, покрывающие видимую область и по одной соседней странице с каждой стороны. При положении прокрутки в начале это страницы с `offset=0` и `offset=<размер страницы>`; вторая запрашивается сразу, чтобы при подходе к её строкам не было заглушек. Вместе со страницами запрашиваются варианты фильтров. Полная длина полосы прокрутки SHALL соответствовать общему числу строк по текущим условиям. Строки, которые ещё не пришли, SHALL показываться пустыми строками-заглушками той же высоты: без значений и без прочерков. Когда видимая область доходит до незагруженных строк, экран SHALL их запросить и заменить заглушки данными без сдвига прокрутки. Экран SHALL NOT менять положение прокрутки программно: оно ведёт себя так же, как раньше при смене данных в той же таблице. В памяти SHALL держаться строки не более чем фиксированного числа страниц, независимо от общего числа строк. #### Scenario: Первая загрузка «Все ССП/РФ» -- **WHEN** пользователь выбирает «Все ССП/РФ» на вкладке «ССП» и нажимает «Загрузить» -- **THEN** экран отправляет один запрос страницы с `offset=0` и один запрос вариантов фильтров, показывает первые строки, а высота прокрутки соответствует `total` строкам +- **WHEN** пользователь выбирает «Все ССП/РФ» на вкладке «ССП» и нажимает «Загрузить», а прокрутка в начале +- **THEN** экран запрашивает варианты фильтров и страницы `offset=0` и `offset=<размер страницы>`, показывает первые строки, а высота прокрутки соответствует `total` строкам #### Scenario: Прокрутка в середину и в конец - **WHEN** пользователь перетаскивает полосу прокрутки в середину, а затем в самый конец списка @@ -17,74 +38,69 @@ #### Scenario: Память не растёт при прокрутке до конца - **WHEN** пользователь прокручивает лист RF «все» до конца и обратно -- **THEN** число строк с данными, которые экран держит в памяти, не превышает предела окна (число страниц окна × размер страницы) +- **THEN** число строк с данными, которые экран держит в памяти, не превышает предела (число страниц кэша × размер страницы) ### Requirement: Поиск и колоночные фильтры через сервер -Изменение строки поиска (после существующей задержки ввода 300 мс) и изменение колоночного фильтра «Вид сметы», «Направление» или «ССП» SHALL перезапрашивать данные с сервера с начала списка, если по текущим году и ССП/РФ данные уже загружены. Если данные не загружены, перезапроса не происходит, как и сейчас. Варианты в колоночных фильтрах SHALL браться из ответа сервера с вариантами для текущих года и ССП/РФ. Ответ на устаревший запрос (после него условия уже поменялись) SHALL NOT попадать на экран. Как и сейчас, изменение поиска, года или ССП/РФ сбрасывает колоночные фильтры текущей вкладки. +Изменение строки поиска (после существующей задержки ввода 300 мс) и изменение колоночного фильтра «Вид сметы», «Направление» или «ССП» SHALL перезапрашивать с сервера страницы видимой области и варианты фильтров, если по текущим году и ССП/РФ данные уже загружены. Если данные не загружены, запросов нет, как и сейчас. До прихода ответа экран SHALL показывать прежние строки. Ответ на устаревший запрос (после него условия уже поменялись) SHALL NOT попадать на экран. Варианты каждого колоночного фильтра SHALL совпадать с теми, что экран показывал до изменения: по строкам, найденным поиском и отобранным фильтрами двух других колонок, с числом строк у каждого варианта, без пустых значений, в прежнем порядке сортировки. #### Scenario: Поиск - **WHEN** данные загружены и пользователь вводит «аренда» в поле поиска -- **THEN** через 300 мс после последнего нажатия экран запрашивает первую страницу с этим поиском и показывает только найденные строки с начала списка +- **THEN** через 300 мс после последнего нажатия экран запрашивает строки и варианты с этим поиском и показывает тот же набор строк, что до изменения + +#### Scenario: Варианты фильтра зависят от поиска и других фильтров +- **WHEN** задан поиск и выбран фильтр «Вид сметы» +- **THEN** в фильтрах «Направление» и «ССП» перечислены значения только из найденных строк этого вида сметы, с числами строк, а в фильтре «Вид сметы» — значения найденных строк без учёта его собственного выбора #### Scenario: Быстрая смена условий - **WHEN** пользователь вводит поиск и, не дожидаясь ответа, выбирает колоночный фильтр «Направление» - **THEN** на экране остаются строки, отвечающие обоим условиям, а ответ на первый запрос не показывается -#### Scenario: Варианты фильтра «ССП» -- **WHEN** загружен лист с тремя организациями -- **THEN** в фильтре «ССП» перечислены ровно эти три организации, и выбор одной из них оставляет только её строки - ### Requirement: Счётчик «Найдено N из M» -Когда задан поиск или хотя бы один колоночный фильтр и число строк по условиям N отличается от общего числа строк M по текущим году и ССП/РФ, экран SHALL показывать «Найдено N из M строк». N берётся из `total` ответа страницы, M — из ответа с вариантами фильтров. Числа форматируются с разделителями `ru-RU`. Иначе подпись не показывается. +Подпись «Найдено N из M строк» SHALL вести себя как до изменения: +- N — число строк, найденных поиском, без учёта колоночных фильтров; +- M — число всех строк по текущим году и ССП/РФ; +- подпись видна, когда данные загружены, загрузка по «Загрузить»/«Обновить» не идёт, и N ≠ M; +- числа форматируются `ru-RU`. -#### Scenario: Счётчик учитывает и поиск, и фильтры -- **WHEN** загружено 5 563 строки, задан поиск и фильтр «Вид сметы», и по ним найдено 120 строк -- **THEN** показано «Найдено 120 из 5 563 строк» +#### Scenario: Колоночный фильтр не меняет счётчик +- **WHEN** загружено 5 563 строки, поиск находит 300, и пользователь выбирает фильтр «Вид сметы», после которого видно 120 строк +- **THEN** подпись остаётся «Найдено 300 из 5 563 строк» ### Requirement: Вкладки и обновление -У каждой вкладки («ССП», «РФ») SHALL сохраняться своё состояние (условия, загруженное окно строк, общее число строк). Возврат на вкладку показывает её строки без нового запроса, если её условия не менялись. «Обновить» SHALL перезапрашивать варианты фильтров и строки текущей вкладки с сервера. Смена года или ССП/РФ, как и сейчас, требует нажать «Загрузить». Свод 2 SHALL NOT сохранять полные ответы в общем кэше сводов. Поведение свода 1 не меняется. +Возврат на ранее загруженную вкладку SHALL показывать её строки, как до изменения. Запрос к серверу допускается только за страницами, которых нет в ограниченном кэше вкладки. «Обновить» SHALL перезапрашивать данные текущей вкладки с сервера. Смена года или ССП/РФ, как и сейчас, требует нажать «Загрузить». Свод 2 SHALL NOT сохранять полные ответы в общем кэше сводов. Поведение свода 1 не меняется. #### Scenario: Переключение вкладок -- **WHEN** загружены «ССП» и «РФ», пользователь возвращается на «ССП» -- **THEN** строки «ССП» видны сразу, без запроса страницы +- **WHEN** загружены «ССП» и «РФ» при прокрутке в начале, пользователь возвращается на «ССП» +- **THEN** строки «ССП» видны сразу, без запроса страниц #### Scenario: Обновить - **WHEN** пользователь нажимает «Обновить» -- **THEN** экран заново запрашивает варианты фильтров и первую страницу и показывает актуальные данные с начала списка - -### Requirement: Неизменное отображение строк -Колонки, их группы, порядок, ширины, закреплённые колонки, форматирование чисел и прочерков (`-` для нуля, `—` для пустого), единицы (руб./тыс./млн), бейджи вида сметы и ССП, «Перейти к задачам» SHALL выглядеть и работать так же, как до изменения. Отображение SHALL NOT зависеть от того, пришло ли значение `null` или поле отсутствует. - -#### Scenario: Визуальный паритет -- **WHEN** на одних данных открыть GO «все» до и после изменения и сравнить первые экраны и три позиции горизонтальной прокрутки -- **THEN** значения ячеек, прочерки и оформление совпадают +- **THEN** экран заново запрашивает варианты фильтров и страницы видимой области и показывает актуальные данные ### Requirement: Ошибки загрузки -Если не удалась первая страница или варианты фильтров, экран SHALL показывать, как и сейчас, сообщение об ошибке с кнопкой «Повторить». Если не удалась одна из следующих страниц, экран SHALL оставить на её месте заглушки, сообщить об ошибке всплывающим уведомлением и повторить запрос, когда эта область снова окажется в видимой зоне. +Если не удался запрос по «Загрузить», «Обновить», поиску или колоночному фильтру, экран SHALL показывать, как и при ошибке загрузки сейчас, сообщение об ошибке с кнопкой «Повторить». Если не удалась догрузка страницы при прокрутке, экран SHALL оставить на её месте заглушки, показать всплывающее уведомление и повторить запрос, когда эта область снова окажется в видимой зоне. #### Scenario: Ошибка догрузки - **WHEN** запрос страницы при прокрутке завершился ошибкой 500 - **THEN** уже показанные строки остаются, на месте страницы — заглушки, показано уведомление; при повторной прокрутке к этому месту запрос повторяется -### Requirement: Память страницы свода 2 -На стенде замеров сценарий `perf/e2e/scenarios/svod2-memory.spec.ts` (роль dfip: GO «Все ССП/РФ» → «Загрузить» → вкладка RF «Все ССП/РФ» → «Загрузить» → вкладка GO, затем сборка мусора) SHALL показывать JS-кучу `heap_used_mb` в конечной точке: -- на масштабе 1 — не больше 30 % значения «до»; -- на масштабе 2 — не больше 1,25 значения «после» на масштабе 1, то есть куча не растёт вместе с объёмом данных. +### Requirement: Замеры производительности экрана +Для GO и RF на масштабах 1 и 2 (роль dfip, CPU 1×, одни данные, один и тот же код сценариев для «до» — вершины AURORA-1842 — и «после») SHALL быть сняты и приведены в PR: +- **память** (`perf/e2e/scenarios/svod2-memory.spec.ts`: GO «Все ССП/РФ» → «Загрузить» → RF «Все ССП/РФ» → «Загрузить» → GO, сборка мусора) — `heap_used_mb` в конечной точке; +- **время до первых строк** — `first_rows_ms`, от «Загрузить» до 20 строк с данными, заглушки не считаются; +- **шаг прокрутки** — `main_thread_ms_per_step` (масштаб 1) и доля кадров с заглушками. -#### Scenario: Замер памяти до и после -- **WHEN** сценарий выполнен на одних данных для сборки «до» (вершина AURORA-1842) и «после», на масштабах 1 и 2, по 3 повтора -- **THEN** медиана `heap_used_mb` в конечной точке «после» на масштабе 1 не больше 0,30 × «до», а на масштабе 2 — не больше 1,25 × «после» на масштабе 1 +Целевые значения: +- память на масштабе 1 — не больше 30 % «до», на масштабе 2 — не больше 1,25 значения «после» на масштабе 1; +- время до первых строк — не больше 50 % «до»; +- шаг прокрутки — не больше 1,10 значения «до». -### Requirement: Время до первых строк -Время от нажатия «Загрузить» при «Все ССП/РФ» до появления в таблице не менее 20 строк с данными (заглушки не считаются) SHALL быть не больше 50 % значения «до» для GO и RF на масштабах 1 и 2. +Цифры условием слияния не являются: недобор оформляется follow-up задачей. -#### Scenario: Замер времени до первых строк -- **WHEN** `scroll.spec.ts` для `svod/svod2_GO` и `svod/svod2_RF` (роль dfip) пишет метрику `first_rows_ms` для сборок «до» и «после» на одних данных -- **THEN** медиана `first_rows_ms` «после» не больше 0,5 × медианы «до» для каждого листа и масштаба +#### Scenario: Замер памяти +- **WHEN** `svod2-memory.spec.ts` выполнен для «до» и «после» на масштабах 1 и 2, по 3 повтора +- **THEN** в PR приведены медианы `heap_used_mb` по точкам и их отношение к целевым значениям, недобор записан как follow-up -### Requirement: Шаг прокрутки не хуже AURORA-1842 -Цена шага вертикальной прокрутки свода 2 (`main_thread_ms_per_step` в `scroll.spec.ts`, роль dfip, CPU 1×) SHALL быть не больше 1,10 значения «до» (вершина AURORA-1842, снятая тем же кодом сценария на тех же данных) для GO и RF на масштабе 1. Доля кадров с видимыми заглушками SHALL записываться рядом с ценой шага. - -#### Scenario: Замер шага прокрутки +#### Scenario: Замер времени до первых строк и шага прокрутки - **WHEN** `scroll.spec.ts` с `PERF_TARGETS='svod/svod2_*'`, `PERF_ROLES=dfip`, `--grep-invert filter` выполнен для «до» и «после» -- **THEN** медиана `main_thread_ms_per_step` «после» не больше 1,10 × «до» для каждого листа, а в записи есть метрика доли кадров с заглушками +- **THEN** в записях есть `first_rows_ms`, `main_thread_ms_per_step` и доля кадров с заглушками, а в PR приведено сравнение с целевыми значениями -- 2.47.2 From 48f422df3aa225ed91c6b9dc50e576a4ba13df95 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:49:25 +0400 Subject: [PATCH 03/17] =?UTF-8?q?docs(openspec):=20aurora-1843=20=E2=80=94?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D0=B8=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20(#22=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../aurora-1843-svod2-server-paging/design.md | 16 ++++++++-------- .../specs/summary2-api/spec.md | 8 ++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index 0384971..0090955 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -150,10 +150,10 @@ | параметр | тип | ограничение | |---|---|---| | `view` | `Literal["full","compact"] \| None` | | -| `search` | `str \| None` | `max_length=1000` | -| `smeta_type` | `str \| None` | `max_length=1000` | -| `smeta_direction` | `str \| None` | `max_length=1000` | -| `org_name` | `str \| None` | `max_length=1000` | +| `search` | `str \| None` | без ограничения длины | +| `smeta_type` | `str \| None` | без ограничения длины | +| `smeta_direction` | `str \| None` | без ограничения длины | +| `org_name` | `str \| None` | без ограничения длины | Значения фильтров — свободные строки, а не перечисления: семантика `equals` (регистр и пробелы по краям не важны) принимает и «кв», и «КВ ». Неизвестное значение даёт 0 строк, как на экране. @@ -442,7 +442,7 @@ PR создаются по готовности, merge — по команде - Совместимость с PG15.12 — отдельный шаг плана «проверка контроллером», контейнер `dfip-pg15`, порт 55434. **Фаза 1, `api/tests/unit`:** -- валидация (по образцу `test_query_validation.py`): 422 на `search`/фильтры длиннее 1000 и на прежние невалидные параметры; +- валидация (по образцу `test_query_validation.py`): 422 на прежние невалидные параметры; `search` и фильтры длиной 5000 символов дают 200 (без ограничения длины); - сервис: - `_allowed_org_ids` применяется к страницам и вариантам; - пустое пересечение даёт пустой ответ без SQL; @@ -624,6 +624,6 @@ PR создаются по готовности, merge — по команде 21. **2026-10-28 — значения для поиска строятся теми же выражениями, что `data` ответа (`header`, `contract_summary`, `contract_detail` листа), объединяются `jsonb ||` (перекрытие как у JS-спреда), числа приводятся к тексту функцией `svod2_js_number_text` (правила `Number::toString` поверх кратчайшей записи `float8`), обрезка — по набору пробельных символов ECMAScript.** Обоснование: решение человека 9 — точное повторение `matchesSearch`. Цена ошибки: если есть расхождение, оно ловится оракулом Node на данных стенда до слияния. -22. **2026-10-28 — ограничение длины `search` и значений фильтров — 1000 символов (422).** - Обоснование: защита от очень длинных URL. На экране ограничения нет, но поиск длиннее 1000 символов нереалистичен. - Цена ошибки: пользователь с таким запросом увидит ошибку вместо пустого результата. Снять ограничение — одна строка. +22. **2026-10-28 — ~~ограничение длины `search` и значений фильтров — 1000 символов (422)~~. Отменено контроллером 28.10: противоречит решению человека 9 («поведение абсолютно такое же»).** + Новая формулировка: API не ограничивает длину `search` и значений фильтров и не отвечает ошибкой из-за длины; длинный запрос даёт тот же результат, что на экране (совпадения есть или нет). Защита от злоупотребления — только невидимая пользователю: общий предел длины строки запроса у веб-сервера (uvicorn/h11 и прокси платформы, порядка килобайт и выше) остаётся как есть, отдельного предела в API не вводим. + Цена ошибки: очень длинный URL (десятки КБ) может упереться в предел прокси до приложения — на экране это нереалистичный ввод; если понадобится, фронт переведёт запросы на POST — полдня. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md index 2ddabe0..9d68073 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/specs/summary2-api/spec.md @@ -45,7 +45,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. +Поиск сочетается с остальными условиями по «И». Длина `search` API не ограничивает: любой запрос даёт тот же результат, что поиск экрана (совпадения есть или их нет), без ошибки. #### Scenario: Поиск без учёта регистра по кириллице независимо от локали - **WHEN** в данных есть строка с `header.name = «Аренда помещения»`, база работает с `LC_CTYPE=C` или `en_US.utf8`, и выполняется запрос с `search=аРЕНДА` @@ -63,6 +63,10 @@ - **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` @@ -72,7 +76,7 @@ - **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`, как и раньше, пересекается с организациями, разрешёнными пользователю. +Параметры `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=КВ` -- 2.47.2 From c7ef4032d6aab29677453a4785c12692959d09f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:06:06 +0400 Subject: [PATCH 04/17] =?UTF-8?q?docs(plan):=20aurora-1843=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три фазы — три PR стеком: сервер (SQL, API, тесты-оракулы первыми), фронт (окно страниц в той же таблице), стенд замеров. Решения плана дописаны в design.md (#23–#34). Co-Authored-By: Claude Opus 5.5 --- ...6-10-28-aurora-1843-svod2-server-paging.md | 996 ++++++++++++++++++ .../aurora-1843-svod2-server-paging/design.md | 46 + 2 files changed, 1042 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md diff --git a/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md b/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md new file mode 100644 index 0000000..9c66a27 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-aurora-1843-svod2-server-paging.md @@ -0,0 +1,996 @@ +# AURORA-1843 — свод 2: серверные страницы, поиск, фильтры и компактный ответ. План реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Свод 2 (`/svod/2`) получает строки страницами с сервера, поиск, три колоночных фильтра и их варианты считает сервер, а в памяти вкладки держится окно строк. Для пользователя поведение экрана не меняется (решение человека 9 от 28.10). + +**Architecture:** +- **Сервер.** Одна миграция `0053`: + - локаленезависимые нормализаторы `svod2_casefold`, `svod2_js_trim` и `svod2_js_number_text`, плюс проекция `svod2_compact`; + - общий отбор `svod2_lines`; + - новые `v_svod2_go`/`v_svod2_rf` с поиском, фильтрами и `total_count`; + - `v_svod2_facets`. + + Обработчик переходит в новый режим, только если в запросе есть хотя бы один новый параметр. Без них ответ байт-в-байт прежний. +- **Фронт.** Та же таблица MRT получает срез окна и отступы виртуализатора. Чистая логика лежит в `svod2Paging.js` (функции окна и редьюсер), хук `useSvod2Pages.js` ходит в API. Варианты фильтров приходят с сервера через переопределённый `getFacetedUniqueValues`. +- **Корректность доказывают оракулы.** Дословная JS-копия правил экрана «до» (`perf/analysis/svod2_search_oracle.mjs`) порождает векторы. Python-порт проверяется на этих векторах и служит оракулом интеграционных тестов API на засеянных данных. На стенде тот же JS-оракул сверяет сервер с прежним полным ответом. + +**Tech Stack:** Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, Alembic, PL/pgSQL / SQL (PostgreSQL 15/16), pytest; React 18, MRT 3.2.1, TanStack Table 8.20.5 / virtual-core 3.11.2, axios, `node --test` (Node 24), Biome; Playwright (стенд `perf/`). + +**Spec:** `openspec/changes/aurora-1843-svod2-server-paging/` (`proposal.md`, `design.md`, `specs/summary2-api/spec.md`, `specs/summary2-screen/spec.md`) + +**Issue:** Gitea #236, Jira AURORA-1843 (эпик AURORA-1834). + +**Ветки (три фазы = три PR, стеком):** + +| фаза | ветка | база PR | от чего ответвляется | +|---|---|---|---| +| 1 — сервер | `feat/236-aurora-1843-svod2-server-paging` | `feat/220-aurora-1842-svod2-column-virtual` (PR #227) | вершина 1842 `399234a` + коммиты OpenSpec | +| 2 — фронт | `feat/236-aurora-1843-svod2-server-paging-web` | `feat/236-aurora-1843-svod2-server-paging` | вершина ветки фазы 1 после Task 8 | +| 3 — стенд | `feat/236-aurora-1843-svod2-server-paging-perf` | `feat/236-aurora-1843-svod2-server-paging-web` | вершина ветки фазы 2 после Task 13 | + +**База стека:** `feat/220-aurora-1842-svod2-column-virtual` @ `399234a` (AURORA-1842, PR #227), она стоит на AURORA-1835 (PR #217) → `test`. + +**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1843` (дальше `$WT`). Все три ветки ведутся в нём последовательно (`git switch -c`). + +## Global Constraints + +**Замок ночного прогона (правило волны 2).** +- Перед каждым тяжёлым шагом исполнитель выполняет `WAIT_LOCK` и ждёт снятия замка: + - тяжёлые шаги — pytest, alembic, `npm run build`, `node --test`, biome по многим файлам, запуск API или фронта, docker; + - команду ожидания запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго, повторять проверку. + + ```bash + L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done + ``` +- Читать и писать код и коммитить можно и при замке. +- Контроллер SDD передаёт это правило дословно в бриф каждого исполнителя и ревьюера. + +**Стенд `perf/` исполнителям не запускать вообще:** БД `perf`, порты 8000/8080/8081/8082, `perf/stand.sh`, `perfkit.*`, Playwright-сценарии. Замеры, reset стенда, ручную проверку и сверку на стенде делает главный контроллер. + +**Окружение тестов.** +- Python — только `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/` (pytest, alembic). Свой venv не создавать. +- `API_TEST <пути>` — интеграционный прогон на PG16 (`perf-pg`, порт 55432, своя БД `dfip_1843`). БД пересоздаётся перед каждым прогоном с `--init_db`. Дословно: + ```bash + docker exec perf-pg dropdb -U perf --if-exists dfip_1843 && docker exec perf-pg createdb -U perf dfip_1843 && cd $WT/api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути> --init_db + ``` +- `UNIT_TEST <пути>` — `cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути>`, без БД. +- БД `perf` не трогать никогда. +- Исходное состояние полного набора — 3 падения, которые не наши: + - `test_backend_calls_form3_add_project_and_add_line_functions`; + - два теста в `test_export_service_query_params`. + + Новых падений быть не должно. +- PG15.12 (`dfip-pg15`, порт 55434) проверяет главный контроллер (шаги «передать контроллеру» в Task 8). +- Фронт: `web/node_modules` — симлинк, `npm install` не запускать. Доступны `cd $WT/web && node --test <файлы>`, `npx biome check <файлы>`, `npm run build`. +- `perf/e2e/node_modules` у исполнителя нет. TypeScript-логику стенда выносить в чистые модули без импортов и только со стираемым синтаксисом (без `enum`, `namespace`, parameter properties). Тесты к ним — `node --test perf/e2e/tests/<файл>.test.ts`: Node 24 сам стирает типы. Playwright-прогоны и проверку типов делает контроллер. +- Python-тесты стенда: `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/<файл>`. +- Карта кода: `mcp__codegraph__codegraph_explore` с `projectPath=$WT`. Implementer вызывает его по своей области перед изменением. + +**Правила кода и данных.** +- Совместимость с PostgreSQL 15: без `any_value`, конструкторов SQL/JSON, `IS JSON` и `JSON_TABLE`; у подзапросов в `FROM` нужны алиасы. +- `v3.v_svod2_go_export` и `SummaryRepository.get_svod2_rows` не менять. +- `ROW_VIRTUALIZER_OPTIONS` в `Svod2Page.jsx:26-31` не править: это hunk AURORA-1836. Опции собираются в компоненте. +- `SvodContext`, `SvodFilters` и свод 1 не менять. +- Эталон `api/tests/fixtures/svod2_legacy_golden.json` пишется один раз в Task 3, на коде «до». Потом его не перезаписывать: если тест эталона упал, ищется причина в коде, а не меняется эталон. +- Семантику экрана не «улучшать»: перекрытие полей `contract_summary`, поиск по техническим id и запись чисел в JS сохраняются. Находки по ним — `deferred`. +- Миграция: рабочий номер `0053`, `down_revision = "0049"`. Окончательный номер «голова + 1» выбирается при rebase (design, «Совместимость»). + +**Коммиты и PR.** +- Сообщения коммитов по-русски, в стиле репозитория: `perf(api): …`, `perf(web): …`, `test(api): …`, `perf: …` для `perf/`, `docs(…)`. +- `git add` только конкретных файлов. Последняя строка сообщения — `Co-Authored-By: Claude Opus 5.5 `. +- `.bounded` не создавать. `git stash` без тега не использовать. Не мержить, не пушить в `test`/`main`. +- Между фазами остановок для человека нет: PR создаётся, и сразу начинается следующая фаза. Merge — только по команде человека. +- Решения без обсуждения дописываются в `openspec/changes/aurora-1843-svod2-server-paging/design.md` → `## Decisions made without discussion`: дата, решение, обоснование, цена ошибки. Нумерация продолжается с 35. + +**Маркировка находок.** +- `T.` — minor-находка ревьюера задачи n. Номера задач сквозные по плану, 1–18. +- `C-n`/`I-n`/`M-n` — финальное ревью ветки. Нумерация сквозная по фиче: фаза 2 продолжает номера фазы 1, фаза 3 — номера фазы 2. +- `R-n (design #m)` — рулинг контроллера, `O-n` — наблюдение контроллера. +- Статус каждой находки: `fixed `, `deferred` или `rejected <причина>`. +- Таблица `## Findings` (`| Маркер | Суть | Статус |`) ведётся в конце ledger и копируется в каждый PR. +- Две находки design.md («Найденные странности»: перекрытие полей поиска, поиск по техническим id) заносятся в таблицу сразу как `O-1` и `O-2` со статусом `deferred`. + +## Review Focus + +1. **Строка без строки договора** (`contract_detail` нет в БД, `data.contract_detail` — пустая секция с `null`). Человек ждёт, что поиск по `contract_summary.counterparty` такой строки ведёт себя как на экране «до»: поля перекрыты `null`, строка не находится. Если `cd_j` в SQL окажется `NULL`, оператор `||` даст `NULL`, и строка пропадёт из поиска по любому полю. Тест: Task 3 (засев) + `test_search_matches_screen_oracle[*-лютик]`. +2. **Названия организаций, отличающиеся регистром или пробелом по краям** (`«Филиал Ёлки»` и `«филиал ёлки »`). Человек ждёт два варианта в фильтре «ССП» (у каждого своё число), а выбор любого из них отбирает строки обеих организаций (MRT `equals`). Тест: Task 1 (векторы) и Task 3 (`test_filters_match_screen_oracle[org-case-variant]`, `test_facets_match_screen_oracle[*]`). +3. **Поиск или фильтр при прокрутке глубоко вниз:** `total` уменьшился ниже текущего `scrollTop`. Человек ждёт, что браузер сам ограничит прокрутку и покажет последние строки, а не пустоту и не запрос `offset ≥ total`. Тест: Task 10 (`getVisiblePage` прижимает к последней странице, `getWindowPages` при `total = 0` пуст). +4. **Быстрая смена условий:** ответ на старый запрос приходит после ответа на новый. Человек ждёт на экране только строки, варианты и «Найдено» нового запроса. Тест: Task 11 (устаревшие `page`/`facets`/`fail` игнорируются). +5. **Данные изменились между запросами страниц** (страница пришла короче или длиннее ожидаемого, `total` другой). Человек ждёт, что строки не съезжают: высота прокрутки по последнему `total`, недостающие места — заглушки. Ошибка догрузки оставляет заглушки и повторяет запрос при возврате к этому месту. Тест: Task 10 (`buildWindowRows` дополняет и обрезает) и Task 11 (`fail` в режиме `scroll` не трогает состояние, страница снова в `getMissingPages`). + +--- + +# Фаза 1 — сервер (ветка `feat/236-aurora-1843-svod2-server-paging`, PR в `feat/220-aurora-1842-svod2-column-virtual`) + +Тесты-оракулы идут первыми (Task 1–3). К концу Task 3 интеграционные тесты нового режима красные. SQL и API (Task 4–7) делают их зелёными. + +### Task 1: JS-оракул экрана «до» и векторы + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `perf/analysis/svod2_search_oracle.mjs` +- Create: `perf/analysis/svod2_search_oracle.test.mjs` +- Create (порождается скриптом): `api/tests/fixtures/js_number_text_vectors.json`, `api/tests/fixtures/svod2_screen_vectors.json` + +**Interfaces:** +- Consumes: код «до» — `git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx` (`matchesSearch`, `:283-294`; поиск `:387-391`), `web/node_modules/material-react-table/dist/index.esm.js` (`equals`, `:663-667`; `useDropdownOptions`, `:203-218`), `web/node_modules/@tanstack/table-core/build/lib/utils/getFacetedRowModel.js`, `getFacetedUniqueValues.js`. +- Produces (ESM-экспорты): + - `matchesSearch(row, query): boolean` — **дословная** копия строк `:283-294` (`const matchesSearch = …`), экспорт отдельной строкой `export { matchesSearch, equals }`; + - `equals(row, id, filterValue): boolean` — **дословная** копия MRT `equals` (`:663-667`, с `var _a;`). Вызывается на объекте `{ getValue: (id) => row.data?.header?.[id] }`: это `accessorFn` колонок `valueAt(row, ['header', id])`; + - `FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']`; + - `screenRows(rows, { search = '', filters = {} } = {}): object[]`. Поиск — как `filteredRows` «до»: `search.trim().toLowerCase()`, пустой не фильтрует. Затем MRT-фильтр `equals` по каждому `filters[id]`, у которого значение истинно (`equals.autoRemove = (val) => !val`). Порядок строк сохраняется; + - `screenFound(rows, search): { found: number, total: number }` — N и M «Найдено» «до»; + - `screenFacets(rows, { search = '', filters = {} } = {}): { total, search_total, smeta_type: [string, number][], smeta_direction: [...], org_name: [...] }`. Для колонки `c` берутся строки, найденные поиском, и к ним применяются активные фильтры всех колонок, кроме `c` (правило `filterableIds` в `getFacetedRowModel`). Затем подсчёт по точному значению (`Map`, как `getFacetedUniqueValues`). `null`/`undefined` отбрасываются, сортировка `(a, b) => a.localeCompare(b)` — дословный фрагмент `useDropdownOptions`; + - `NUMBER_VECTOR_INPUTS: string[]` (JSON-литералы), `SYNTHETIC_ROWS: object[]`, `SCREEN_CASES: { search: string, filters: object }[]`; + - CLI: `node perf/analysis/svod2_search_oracle.mjs --emit-vectors ` пишет `/js_number_text_vectors.json` (`[{ "input": "1234.50", "js": "1234.5" }, …]`, где `js = String(JSON.parse(input))`) и `/svod2_screen_vectors.json` (`{ "rows": SYNTHETIC_ROWS, "cases": [{ "search", "filters", "line_ids", "found": { "found", "total" }, "facets": }] }`). + +`NUMBER_VECTOR_INPUTS` содержит как минимум: `0`, `-0`, `1`, `-1`, `100`, `100.00`, `1234.50`, `12.34`, `0.5`, `0.30000000000000004`, `0.000001`, `1e-7`, `-1.5e-10`, `123e-20`, `1e15`, `1e20`, `1e21`, `123456789012345680000`, `9007199254740993`, `5e-324`, `1.7976931348623157e308`. + +`SYNTHETIC_ROWS` — 10–15 строк в форме `{ data: { line_id, header: {…}, contract_summary: {…}, contract_detail: {…} } }`. В них есть: +- кириллица разного регистра, `Ё`/`ё`; +- NBSP и `U+FEFF` внутри значений; +- перекрытое поле (`contract_summary.counterparty = 'ООО Ромашка'` при `contract_detail.counterparty = null`) и поле договора (`contract_detail.counterparty = 'ООО Ромашка Плюс'`); +- числа `1234.5`, `100`, `0`, `1e-7`; `true`; `null`; +- `smeta_direction = null`; +- две строки с одинаковым `org_name` и одна с вариантом `'филиал ёлки '`. + +`SCREEN_CASES` — не меньше 12 сочетаний: пустой поиск; `'аРЕНДА'`; `'ё'`; `'ромаш'`; `'1234.5'`; `'1234.50'`; `' аренда '`; `' '`; `'1'`; фильтр `'кв '`; фильтр по названию организации; два фильтра вместе; поиск вместе с фильтром. + +- [ ] **Step 1: Написать падающие тесты** в `perf/analysis/svod2_search_oracle.test.mjs` (`node:test`, `node:assert/strict`): + +```js +test('перекрытое поле contract_summary не ищется', () => { + const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } }; + assert.equal(matchesSearch(row, 'ромаш'), false); +}); +test('число ищется в записи JS', () => { + const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } }; + assert.equal(matchesSearch(row, '1234.5'), true); + assert.equal(matchesSearch(row, '1234.50'), false); +}); +test('screenRows: trim + lower, NBSP по краям', () => { + const rows = [{ data: { line_id: 1, header: { name: 'Аренда помещения' } } }]; + assert.deepEqual(screenRows(rows, { search: ' АРЕНДА ' }).map((r) => r.data.line_id), [1]); +}); +test('equals: регистр и пробелы по краям, null не проходит', () => { + const r = (v) => ({ getValue: () => v }); + assert.equal(equals(r('КВ'), 'smeta_type', 'кв '), true); + assert.equal(equals(r(null), 'smeta_type', 'кв'), false); +}); +test('screenFacets: свой фильтр не применяется, чужие — да', () => { /* строки: (КВ, Развитие), (АХР, Поддержка) */ + const f = screenFacets(rows2, { filters: { smeta_type: 'КВ' } }); + assert.deepEqual(f.smeta_type, [['АХР', 1], ['КВ', 1]]); + assert.deepEqual(f.smeta_direction, [['Развитие', 1]]); +}); +test('screenFacets: одинаковые названия — один вариант, null не выдаётся', () => { /* два 'Филиал Ёлки', один null */ + assert.deepEqual(screenFacets(rows3).org_name, [['Филиал Ёлки', 2]]); +}); +test('screenFound считает только поиск', () => { + assert.deepEqual(screenFound(rows2, ''), { found: 2, total: 2 }); +}); +test('векторы чисел', () => { + const v = Object.fromEntries(emitNumberVectors().map(({ input, js }) => [input, js])); + assert.equal(v['1e21'], '1e+21'); assert.equal(v['-0'], '0'); assert.equal(v['1e-7'], '1e-7'); + assert.equal(v['0.000001'], '0.000001'); assert.equal(v['100.00'], '100'); assert.equal(v['9007199254740993'], '9007199254740992'); +}); +``` +(`emitNumberVectors(): { input, js }[]` и `emitScreenVectors(): { rows, cases }` — тоже экспорты модуля, CLI их сериализует.) + +- [ ] **Step 2: Запустить и убедиться, что тесты падают.** `WAIT_LOCK`, затем `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`. Ожидается: FAIL, `Cannot find module`. +- [ ] **Step 3: Реализовать модуль** `perf/analysis/svod2_search_oracle.mjs` по Interfaces. `matchesSearch` и `equals` скопировать дословно, не переформатировать. +- [ ] **Step 4: Проверить дословность копий.** + ```bash + cd $WT && git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx | sed -n 283,294p | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo MATCHES_OK + sed -n 663,667p web/node_modules/material-react-table/dist/index.esm.js | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo EQUALS_OK + ``` + Ожидается: `MATCHES_OK`, `EQUALS_OK`. +- [ ] **Step 5: Тесты зелёные.** `WAIT_LOCK`; `node --test perf/analysis/svod2_search_oracle.test.mjs` — PASS. +- [ ] **Step 6: Породить векторы.** `cd $WT && node perf/analysis/svod2_search_oracle.mjs --emit-vectors api/tests/fixtures`. Ожидается: два файла, `svod2_screen_vectors.json` содержит `cases` длиной не меньше 12. +- [ ] **Step 7: Commit.** + ```bash + git add perf/analysis/svod2_search_oracle.mjs perf/analysis/svod2_search_oracle.test.mjs api/tests/fixtures/js_number_text_vectors.json api/tests/fixtures/svod2_screen_vectors.json + git commit -m "test: свод 2 — JS-оракул поиска, фильтров и вариантов экрана «до» и векторы" + ``` + +### Task 2: Python-порт оракула, сверенный с векторами JS + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `api/tests/svod2_oracle.py` (импорт `from tests.svod2_oracle import …`; `pythonpath = .` в `api/pytest.ini`) +- Create: `api/tests/unit/test_svod2_oracle.py` + +**Interfaces:** +- Consumes: `api/tests/fixtures/js_number_text_vectors.json`, `api/tests/fixtures/svod2_screen_vectors.json` (Task 1). +- Produces: + - `FILTER_COLUMNS = ("smeta_type", "smeta_direction", "org_name")`; + - `js_number_text(x: int | float) -> str` — `Number::toString` (ECMAScript): цифры из `repr(float(x))` (кратчайшая запись), затем правила `k ≤ n ≤ 21` / `0 < n ≤ 21` / `−6 < n ≤ 0` / экспонента `e±`, `-0` → `"0"`. Тот же алгоритм, что SQL-функция в design § 3; + - `js_string(value: object) -> str` — `String(value ?? '')`: `None` → `""`, `bool` → `"true"`/`"false"`, число → `js_number_text`, `str` как есть; + - `js_trim(s: str) -> str` — обрезка по набору ECMAScript `WhiteSpace` + `LineTerminator` из design § 3; + - `matches_search(row: dict, query: str) -> bool` — слияние `{**header, **contract_summary, **contract_detail}` (отсутствующая или `None` группа — пустой словарь), `query in js_string(v).lower()`; + - `screen_rows(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> list[dict]`; + - `screen_found(rows: list[dict], search: str) -> tuple[int, int]`; + - `screen_facets(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> dict` → `{"total": int, "search_total": int, "smeta_type": dict[str, int], "smeta_direction": dict[str, int], "org_name": dict[str, int]}`; + - `COMPACT_PATHS: tuple[str, ...]` — пути через точку из требования «Компактное представление строки» (`line_id`, `header.source_form`, …, `q4.actual_spod`, `q1.transfer_to_q2` …); + - `compact_projection(data: dict) -> dict` — ровно то, что даёт `jsonb_strip_nulls(jsonb_build_object(...))` по `COMPACT_PATHS`. Каждая группа и подгруппа (`header`, `plan`, `sequestration.DFIP`, `q1` …) присутствует всегда, возможно пустой. Листья со значением `None` или отсутствующие опускаются, `0` сохраняется. + +- [ ] **Step 1: Написать падающие тесты** `api/tests/unit/test_svod2_oracle.py`: + +```python +FIX = Path(__file__).resolve().parents[1] / "fixtures" +NUM = json.loads((FIX / "js_number_text_vectors.json").read_text()) +SCREEN = json.loads((FIX / "svod2_screen_vectors.json").read_text()) + +def test_js_number_text_matches_node_vectors(): + for v in NUM: + assert js_number_text(json.loads(v["input"])) == v["js"], v + +@pytest.mark.parametrize("case", SCREEN["cases"], ids=lambda c: f"{c['search']!r}-{c['filters']}") +def test_screen_port_matches_node(case): + rows = SCREEN["rows"] + assert [r["data"]["line_id"] for r in screen_rows(rows, case["search"], case["filters"])] == case["line_ids"] + assert screen_found(rows, case["search"]) == (case["found"]["found"], case["found"]["total"]) + f = screen_facets(rows, case["search"], case["filters"]) + assert (f["total"], f["search_total"]) == (case["facets"]["total"], case["facets"]["search_total"]) + for col in FILTER_COLUMNS: + assert f[col] == dict(case["facets"][col]) + +def test_compact_projection_keeps_zero_drops_null_and_unknown(): + out = compact_projection({"line_id": 1, "header": {"source_form": "FORM_1", "name": None, "vsp_id": 5}, + "plan": {"plan_q1": 0, "plan_q2": None}}) + assert out["line_id"] == 1 + assert out["header"] == {"source_form": "FORM_1"} + assert out["plan"] == {"plan_q1": 0} + assert out["sequestration"] == {"DFIP": {}} + assert "transfer_to_q1" not in out["q1"] and set(out) >= {"q1", "q2", "q3", "q4", "contract_detail"} +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_svod2_oracle.py`. Ожидается: FAIL, `ModuleNotFoundError: tests.svod2_oracle`. +- [ ] **Step 3: Реализовать** `api/tests/svod2_oracle.py` по Interfaces. +- [ ] **Step 4:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_svod2_oracle.py` — PASS (все случаи векторов). +- [ ] **Step 5: Commit.** `git add api/tests/svod2_oracle.py api/tests/unit/test_svod2_oracle.py`; сообщение `test(api): свод 2 — Python-оракул экрана, сверенный с векторами JS`. + +### Task 3: Засев свода 2, эталон прежнего ответа и тесты-оракулы API (красные) + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `api/tests/sqls/svod2_seed.sql` +- Modify: `api/tests/integration/conftest.py`: фикстура `client` получает строку `test_client.db_session = db_ref` перед `yield`; новые фикстуры `svod2_data`, `svod2_sql` +- Create: `api/tests/integration/test_svod2_legacy_golden.py` +- Create (порождается тестом): `api/tests/fixtures/svod2_legacy_golden.json` +- Create: `api/tests/integration/test_svod2_paging_api.py` + +**Interfaces:** +- Consumes: `tests.svod2_oracle` (Task 2), фикстуры `client`, `admin_tokens`, `isp_tokens`, `auth_headers`. +- Produces: + - фикстура `svod2_data(client) -> dict` → `{"year": 2031, "org_a": 910001, "org_b": 910002, "org_b_twin": 910003, "org_b_case": 910004, "cd_id": , "ckk_only_value": "777.25"}`. Скрипт `svod2_seed.sql` выполняется на `client.db_session` через `client.portal.call(...)` (тот же цикл событий, что у запросов). Способ выполнения многооператорного скрипта — как в `run_seed_script_async` (`api/tests/conftest.py`). Откат — общий `rollback` фикстуры `client`; + - фикстура `svod2_sql(client) -> Callable[[str, dict | None], list[Row]]` выполняет `text(sql)` на той же сессии через портал (нужна Task 4–5); + - константы теста: `SVOD2_YEAR = 2031`. + +**Требования к `svod2_seed.sql`.** Явные id от `910001`, год `2031` (в `fixture.sql` его нет). Каждая строка проходит отбор 0049: есть хотя бы одна связанная запись с данными. Что засеять: +- организации: A `«Тестовый филиал Альфа»` (910001), B `«Филиал Ёлки»` (910002), двойник B с тем же названием (910003) и `«филиал ёлки »` (910004); +- формы FORM_1 и FORM_4 (лист GO), FORM_2 (лист RF) за 2031 год; +- строки на все значения `smeta_type`, достижимые по `expense_item.sheet`, и на `direction` `Support`, `Development` и `NULL`; +- `header.name = 'Аренда помещения'`, строка с `ЁЛКА` в названии; +- `contract_summary.counterparty = 'ООО Ромашка'` при строке `contract_detail` с `counterparty = NULL` (перекрытие); +- `contract_summary.counterparty = 'ООО Лютик'` **без строки** `contract_detail` (Review Focus 1); +- `contract_detail.counterparty = 'ООО Ромашка Плюс'`; +- `contract_detail.ceiling_amount = 1234.50`, `plan_q1 = 100.00`, `plan_q2 = 0`; +- на листе RF строка, у которой `contract_detail.expenses_q1 IS NULL`, а `ckk.expenses_q1 = 777.25`; +- `v3.user_org(user_id = 2 /* isp1 */, org_unit_id = 910001)`. + +- [ ] **Step 1: Засев и фикстуры.** Написать `svod2_seed.sql` и фикстуры в `conftest.py`. +- [ ] **Step 2: Тест эталона** `test_svod2_legacy_golden.py`. Случаи — `GO` и `RF` × `admin_all` (без параметров), `admin_one` (`org_ids=910001`), `isp_all` (isp1, без параметров), `admin_page50` (`offset=0&limit=50`), `admin_two` (`offset=0&limit=2`). Запись режимом `SVOD2_WRITE_GOLDEN=1`, иначе сравнение: + +```python +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.name) +def test_legacy_response_is_byte_identical(client, svod2_data, admin_tokens, isp_tokens, auth_headers, sheet, case): + r = client.get(case.url(sheet, svod2_data), headers=case.headers(auth_headers, admin_tokens, isp_tokens)) + assert r.status_code == 200 + if case.name == "admin_all": + assert r.json()["result"], "засев не дал строк свода 2" + key = f"{sheet}-{case.name}" + if os.getenv("SVOD2_WRITE_GOLDEN") == "1": + _record(key, r.content) # {"sha256": ..., "body": r.json()} + assert hashlib.sha256(r.content).hexdigest() == _golden()[key]["sha256"] +``` + +- [ ] **Step 3: Записать эталон на коде «до».** `WAIT_LOCK`; `SVOD2_WRITE_GOLDEN=1 API_TEST tests/integration/test_svod2_legacy_golden.py`. Затем без переменной: `API_TEST tests/integration/test_svod2_legacy_golden.py` — PASS, два раза подряд (данные детерминированы). Если sha различаются между прогонами, в ответе есть недетерминированное поле: найти его и стабилизировать засев, эталон переписать до коммита. +- [ ] **Step 4: Красные тесты-оракулы** `test_svod2_paging_api.py`. Вспомогательная `legacy_full(client, sheet, headers, org_ids=None) -> list[dict]` — прежний ответ без параметров; это оракульная база. Тесты: + +```python +def test_legacy_mode_has_no_total(...): # ?offset=0&limit=2 → count ≤ 2, "total" not in body +@pytest.mark.parametrize("q", ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad", "view=bad"]) +def test_invalid_params_422(...): # 422 +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +def test_pages_stitch_to_full_list(...): # view=full, limit=2, offset 0,2,4… пока страница < 2: + # [line_id по страницам] == [line_id legacy_full]; total == len(legacy_full) на каждой странице +def test_page_past_end_has_total(...): # offset=total → result == [], count == 0, total == len(full) +def test_view_full_rows_equal_legacy_rows(...): # RF view=full&offset=0&limit=50: result == legacy_full[:50] (сравнение row dict), "total" in body +SEARCHES = ["аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда  ", + " ", "1", ",", "777.25", "", "я" * 5000] +@pytest.mark.parametrize("sheet", ["GO", "RF"]) @pytest.mark.parametrize("search", SEARCHES) +def test_search_matches_screen_oracle(...): # все страницы view=compact limit=3 с search → line_id == screen_rows(full, search); total == len(...) +FILTERS = {"type-case-space": {"smeta_type": "<значение из засева в нижнем регистре + ' '>"}, + "org-twin": {"org_name": "Филиал Ёлки"}, "org-case-variant": {"org_name": "филиал ёлки "}, + "unknown": {"smeta_type": "нет такого"}, "dir+org": {"smeta_direction": "Развитие", "org_name": "Филиал Ёлки"}, + "search+type": {"search": "аренда", "smeta_type": "<значение>"}, "long": {"org_name": "я" * 5000}} +def test_filters_match_screen_oracle(...): # line_id и total == screen_rows(full, search, filters) +def test_facets_match_screen_oracle(...): # для всех FILTERS и SEARCHES[:6]: /facets → total, search_total, {value: count} == screen_facets(...) +def test_found_matches_screen_oracle(...): # (search_total, total) из /facets == screen_found(full, search) +def test_compact_is_projection_of_full(...): # одна страница view=full и view=compact: row_type/depth/sort_order равны, data == compact_projection(full.data) +def test_rf_ckk_only_value_is_searchable(...): # RF search=777.25 → строка есть (решение #16) +def test_isp_foreign_org_is_empty(...): # isp1, org_ids=910002: страница result == [], total == 0; /facets все нули и пустые списки +def test_isp_sees_only_own_org(...): # isp1 без org_ids: total == число строк org_a в legacy_full(admin) +def test_facets_unknown_sheet_404(...): # /svod/2/XX/2031/facets → 404 +``` + +- [ ] **Step 5: Убедиться, что новые тесты красные, а прежние зелёные.** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py`. Ожидается: + - `test_legacy_mode_has_no_total`, `test_invalid_params_422[offset/limit/sort_*]`, эталон и смоук — PASS; + - `test_invalid_params_422[view=bad]` — FAIL (параметр пока игнорируется, 200); + - остальные новые — FAIL: `KeyError: 'total'`, 404 на `/facets` или несовпадение с оракулом (поиск пока игнорируется). + + Список упавших записать в отчёт задачи. `xfail` не ставить. +- [ ] **Step 6: Commit** (красные тесты в промежуточном коммите допустимы): + ```bash + git add api/tests/sqls/svod2_seed.sql api/tests/integration/conftest.py api/tests/integration/test_svod2_legacy_golden.py api/tests/fixtures/svod2_legacy_golden.json api/tests/integration/test_svod2_paging_api.py + git commit -m "test(api): свод 2 — засев, эталон прежнего ответа и тесты-оракулы нового режима (красные)" + ``` + +### Task 4: Миграция 0053 — нормализаторы и компактная проекция + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `api/alembic/versions/0053_svod2_server_paging.py` (`revision = "0053"`, `down_revision = "0049"`, `run_sql_file_migration("0053_svod2_server_paging.sql")`, `downgrade` — `pass`, как в 0049) +- Create: `api/alembic/versions/sql/0053_svod2_server_paging.sql` +- Create: `api/tests/integration/test_svod2_sql.py` + +**Interfaces:** +- Consumes: `svod2_sql` (Task 3), `js_number_text_vectors.json` (Task 1), `compact_projection` (Task 2). +- Produces SQL: + - `v3.svod2_casefold(text) RETURNS text LANGUAGE sql IMMUTABLE` — `translate` по таблице design § 3: ASCII `A-Z`, U+0410–U+042F, U+0400–U+040F, U+0490, U+00C0–U+00DE кроме U+00D7; + - `v3.svod2_js_trim(text) RETURNS text LANGUAGE sql IMMUTABLE` — `btrim` по набору design § 3; + - `v3.svod2_js_number_text(double precision) RETURNS text LANGUAGE plpgsql IMMUTABLE SET extra_float_digits = 1` — алгоритм design § 3; + - `v3.svod2_compact(jsonb) RETURNS jsonb LANGUAGE sql IMMUTABLE` — `jsonb_strip_nulls(jsonb_build_object(...))` по путям `COMPACT_PATHS`. + +- [ ] **Step 1: Написать падающие тесты** в `test_svod2_sql.py`: + +```python +def test_js_number_text_matches_node_vectors(svod2_sql): + for v in NUM: + got = svod2_sql("SELECT v3.svod2_js_number_text((CAST(:x AS jsonb) #>> '{}')::float8) AS t", {"x": v["input"]})[0].t + assert got == v["js"], v +def test_js_number_text_ignores_session_extra_float_digits(svod2_sql): + svod2_sql("SET LOCAL extra_float_digits = 0", None) + assert svod2_sql("SELECT v3.svod2_js_number_text(0.1::float8 + 0.2::float8) AS t", None)[0].t == "0.30000000000000004" +@pytest.mark.parametrize("src", ["АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", "ЄІЇЎЂЏҐ", "ABCxyz", "ÀÉÖÞ", "×", "аРЕНДА 1"]) +def test_casefold_matches_js_lower(svod2_sql, src): + assert svod2_sql("SELECT v3.svod2_casefold(:s) AS t", {"s": src})[0].t == src.lower() +def test_casefold_independent_of_collation(svod2_sql): + assert svod2_sql("""SELECT v3.svod2_casefold('ЁЛКА' COLLATE "C") AS t""", None)[0].t == "ёлка" +def test_js_trim_ecmascript_whitespace(svod2_sql): + assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "   x 
\t"})[0].t == "x" + assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "​ x"})[0].t == "​ x" +def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_tokens, auth_headers): + full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"] + got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0].c + assert got == compact_projection(full) +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_sql.py`. Ожидается: FAIL, `function v3.svod2_js_number_text(double precision) does not exist` и т. п. +- [ ] **Step 3: Реализовать** четыре функции в `0053_svod2_server_paging.sql` и файл ревизии. +- [ ] **Step 4:** `WAIT_LOCK`; `API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py` — PASS. `cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/alembic heads` → одна голова `0053`. +- [ ] **Step 5: Commit.** `git add` трёх файлов; `perf(api): свод 2 — миграция 0053: нормализаторы поиска как в JS и компактная проекция`. + +### Task 5: Миграция 0053 — общий отбор, страницы с `total_count`, варианты фильтров + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `api/alembic/versions/sql/0053_svod2_server_paging.sql` +- Modify: `api/tests/unit/test_summary2_related_records_sql.py` (перенацелить) +- Modify: `api/tests/integration/test_svod2_sql.py` + +**Interfaces:** +- Consumes: функции Task 4; тела `0049_summary2_related_records.sql` (`v_svod2_go` `:6-303`, `v_svod2_rf` `:305-595`). +- Produces SQL: + - `DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);` и то же для `v3.v_svod2_rf`; + - `v3.svod2_lines(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text) RETURNS TABLE(line_id integer, form_type_code varchar, form_id integer, org_unit_id integer, org_name varchar, section_code varchar, item_id varchar, smeta_type varchar, smeta_direction varchar)`, `LANGUAGE sql STABLE`, без `SET search_path`, все имена с `v3.`. Устройство: + - `WITH eligible_lines AS (…) SELECT … FROM eligible_lines el WHERE p_search IS NULL OR EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e WHERE strpos(v3.svod2_casefold(<текст e.value>), p_search) > 0)`; + - тело `eligible_lines` — отбор 0049 **без изменения условий**, формы по `p_sheet` (`GO` → FORM_1/FORM_4, `RF` → FORM_2), плюс столбец `smeta_direction` (тот же `CASE bl.direction`, что в `header`). Типы столбцов взять из 0049; + - тело отбора обрамить строками-маркерами `-- svod2_lines eligible: begin` и `-- svod2_lines eligible: end` (их читает SQLite-тест); + - `header_j`, `cs_j`, `cd_j` строятся **теми же выражениями**, что `data.header`, `data.contract_summary`, `data.contract_detail` в 0049 для своего листа, включая `COALESCE(…, e.<пустая секция>)` и для RF `expenses_qN = COALESCE(cd, ckk)` (решения #16 и #21); + - текст значения: `string` → `#>> '{}'`, `number` → `v3.svod2_js_number_text((v #>> '{}')::float8)`, `boolean` → `true`/`false`, `null` → `''`; + - `v3.v_svod2_go` и `v3.v_svod2_rf`: + - аргументы `(p_year integer, p_org_unit_ids integer[] DEFAULT NULL, p_offset integer DEFAULT NULL, p_limit integer DEFAULT NULL, p_sort_by varchar DEFAULT 'org_name', p_sort_direction varchar DEFAULT 'asc', p_search text DEFAULT NULL, p_smeta_type text DEFAULT NULL, p_smeta_direction text DEFAULT NULL, p_org_name text DEFAULT NULL, p_compact boolean DEFAULT false)`; + - результат — 8 столбцов 0049 + `total_count bigint`; + - нормализация: поиск — `NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '')`, фильтры — `NULLIF(v3.svod2_js_trim(v3.svod2_casefold(x)), '')`; + - `numbered_lines` строится по `svod2_lines` с тремя фильтрами (`x IS NOT NULL AND v3.svod2_js_trim(v3.svod2_casefold(x)) = <параметр>`), с тем же `ORDER BY`, что в 0049, плюс `count(*) OVER () AS total_count`; + - `page`, `booking_data` и сборка `data` копируются из 0049 без изменений, `data` = `CASE WHEN p_compact THEN v3.svod2_compact(<полный>) ELSE <полный> END`; + - `SET search_path` как в 0049; + - `v3.v_svod2_facets(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text) RETURNS TABLE(kind varchar, value varchar, n bigint)`: + - `kind` ∈ `total`, `search_total`, `smeta_type`, `smeta_direction`, `org_name` (у `total`/`search_total` `value = NULL`); + - одна выборка `svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)` с флагом совпадения поиска; + - для каждой колонки — `GROUP BY` по точному значению при условии «поиск + фильтры двух других колонок», `NULL`-значения не выдаются. + +- [ ] **Step 1: Перенацелить SQLite-тест.** + - `SQL_PATH` → `0053_svod2_server_paging.sql`. + - `self.queries` = [тело между маркерами при `p_sheet = 'GO'`, то же при `'RF'`, тело `eligible_lines` `v_svod2_go_export` из 0049]. Подставлять `p_sheet` литералом; столбец `smeta_direction` SQLite выполнит как есть. + - Пять существующих тестов не меняются по смыслу. Индексы 0/1/2 по-прежнему значат GO/RF/экспорт GO. +- [ ] **Step 2: Добавить падающие тесты** в `test_svod2_sql.py`: + +```python +def test_single_signature_without_overloads(svod2_sql): + rows = svod2_sql("SELECT proname, pronargs FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace " + "WHERE nspname = 'v3' AND proname IN ('v_svod2_go', 'v_svod2_rf', 'v_svod2_go_export')", None) + assert sorted((r.proname, r.pronargs) for r in rows) == [("v_svod2_go", 11), ("v_svod2_go_export", 4), ("v_svod2_rf", 11)] +@pytest.mark.parametrize("fn", ["v_svod2_go", "v_svod2_rf"]) +def test_page_total_count_and_order(svod2_sql, svod2_data, fn): + full = svod2_sql(f"SELECT source_line_id FROM v3.{fn}(2031)", None) + page = svod2_sql(f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, 0, 2, 'org_name', 'asc', NULL, NULL, NULL, NULL, false)", None) + assert [r.source_line_id for r in page] == [r.source_line_id for r in full][:2] + assert {r.total_count for r in page} == {len(full)} +def test_facets_totals(svod2_sql, svod2_data): + rows = {(r.kind, r.value): r.n for r in svod2_sql("SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, NULL, NULL, NULL, NULL)", None)} + assert rows[("total", None)] == rows[("search_total", None)] == len(svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None)) +``` + +- [ ] **Step 3:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_related_records_sql.py` и `API_TEST tests/integration/test_svod2_sql.py`. Ожидается FAIL: маркеров нет; 6 аргументов вместо 11; `v_svod2_facets does not exist`. +- [ ] **Step 4: Реализовать** SQL по Interfaces. +- [ ] **Step 5:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_related_records_sql.py` — PASS. `API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py` — PASS: эталон байт-в-байт и прежние 6-аргументные вызовы не изменились. +- [ ] **Step 6: Commit.** `git add` трёх файлов; `perf(api): свод 2 — общий отбор svod2_lines, страницы с total_count, варианты фильтров в SQL`. + +### Task 6: Репозиторий, сервис и схемы ответа + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `api/src/domain/schemas.py` (после `SheetResponse`, `:357`) +- Modify: `api/src/repository/summary_repository.py` +- Modify: `api/src/services/summary_service.py` +- Create: `api/tests/unit/test_summary2_service.py` + +**Interfaces:** +- Consumes: SQL Task 5. +- Produces: + - `class Svod2PageResponse(BaseListResponse[SheetResponse]): total: int`; + - `class Svod2FacetValue(BaseModel): value: str; count: int`; + - `class Svod2Facets(BaseModel): total: int; search_total: int; smeta_type: list[Svod2FacetValue]; smeta_direction: list[Svod2FacetValue]; org_name: list[Svod2FacetValue]`; + - `SummaryRepository.get_svod2_page(self, year: int, sheet: str, org_ids: list[int] | None, offset: int | None, limit: int | None, sort_by: str, sort_direction: str, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None, compact: bool) -> tuple[list[tuple], int]`: + - строки `('INPUT', 3, sort_order, data)`, как у `get_svod2_rows`; + - `total` = `total_count` первой строки или `0`; + - все параметры через `CAST(:x AS …)`; + - `SummaryRepository.get_svod2_facets(self, year: int, sheet: str, org_ids: list[int] | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> dict` → форма `Svod2Facets` (`{"total", "search_total", "smeta_type": [{"value", "count"}], …}`), отсутствующие виды — `0`/`[]`; + - `SummaryService.get_svod2_page(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, offset: int | None = None, limit: int | None = None, sort_by: str = "org_name", sort_direction: str = "asc", search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None, view: str = "full") -> tuple[list[tuple], int]`; + - `SummaryService.get_svod2_facets(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None) -> dict`; + - `EMPTY_SVOD2_FACETS` — `{"total": 0, "search_total": 0, "smeta_type": [], "smeta_direction": [], "org_name": []}` (функция или копия на каждый вызов). + +- [ ] **Step 1: Написать падающие тесты** (`AsyncMock` на `summary_repo`, `_allowed_org_ids` подменяется `monkeypatch`): + +```python +async def test_page_passes_params_and_compact_flag(): ... # view="compact" → repo.get_svod2_page(..., compact=True); результат ([row], 7) +async def test_page_empty_intersection_skips_sql(): ... # _allowed_org_ids → [] → ([], 0); repo.get_svod2_page.assert_not_awaited() +async def test_page_past_end_second_call_for_total(): ... # 1-й вызов offset=600 → ([], 0); 2-й offset=0, limit=1 → ([row], 42); итог ([], 42) +async def test_page_empty_first_page_no_second_call(): ... # offset=0 → ([], 0); await_count == 1 +async def test_facets_empty_intersection_zeros_without_sql(): ... # == EMPTY_SVOD2_FACETS; repo не вызван +async def test_facets_passes_allowed_org_ids_and_filters(): ... # repo.get_svod2_facets(org_ids=[1], search="x", smeta_type="КВ", ...) +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_service.py` — FAIL (`AttributeError: get_svod2_page`). +- [ ] **Step 3: Реализовать** схемы, методы репозитория и сервиса. +- [ ] **Step 4:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_service.py` — PASS. +- [ ] **Step 5: Commit.** `perf(api): свод 2 — страницы и варианты фильтров в репозитории и сервисе`. + +### Task 7: API — новый режим `GET /svod/2/{sheet}/{year}` и эндпоинт `/facets` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `api/src/api/v1/summary.py` (`get_summary2`, `:93-146`; новый обработчик) +- Create: `api/tests/unit/test_summary2_mode.py` + +**Interfaces:** +- Consumes: Task 6. +- Produces: + - `is_svod2_page_mode(view: str | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> bool` — хотя бы один не `None`; пустая строка — новый режим; + - `get_summary2` с новыми параметрами `view: Optional[Literal["full", "compact"]] = None`, `search: Optional[str] = None`, `smeta_type: Optional[str] = None`, `smeta_direction: Optional[str] = None`, `org_name: Optional[str] = None` — без ограничений длины. Декоратор с явным `response_model=Svod2PageResponse | BaseListResponse[SheetResponse]`. Прежний режим возвращает тот же `BaseListResponse(count=…, result=…)` через `get_svod2_rows`, новый — `Svod2PageResponse(count=len(rows), result=…, total=total)`. `X-DB-Time-Ms` и лог `db_time` (с новыми полями) — в обоих режимах; + - `@router.get("/2/{sheet}/{year}/facets") async def get_summary2_facets(sheet: str, year: int, response: Response, org_ids: Optional[str] = None, search: Optional[str] = None, smeta_type: Optional[str] = None, smeta_direction: Optional[str] = None, org_name: Optional[str] = None, db=…, current_user=…) -> BaseSingleResponse[Svod2Facets]` — `sheet.upper()`; неизвестный лист — 404 с тем же текстом, что у `get_summary2`; `X-DB-Time-Ms`. + +- [ ] **Step 1: Написать падающий unit-тест:** + +```python +@pytest.mark.parametrize("args,expected", [ + ((None, None, None, None, None), False), (("full", None, None, None, None), True), + ((None, "", None, None, None), True), ((None, None, None, None, "x"), True)]) +def test_is_svod2_page_mode(args, expected): + assert is_svod2_page_mode(*args) is expected +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_mode.py` — FAIL (`ImportError`). +- [ ] **Step 3: Реализовать** обработчики. +- [ ] **Step 4: Оракулы зеленеют.** `WAIT_LOCK`; `UNIT_TEST tests/unit/test_summary2_mode.py tests/unit/test_summary2_service.py`; `API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_svod2_sql.py tests/integration/test_summary_api_smoke.py`. Ожидается: всё PASS, в том числе каждый случай `SEARCHES`/`FILTERS` и эталон. + - Если эталон расходится только сериализацией (`response_model`), найти причину в обработчике. Эталон не трогать. + - Если оракул расходится с SQL, починить SQL (Task 5): оракул — дословная семантика экрана. +- [ ] **Step 5: Commit.** `git add api/src/api/v1/summary.py api/tests/unit/test_summary2_mode.py`; `perf(api): свод 2 — новый режим страниц с total и эндпоинт вариантов фильтров`. + +### Task 8: Завершение фазы 1 — полный прогон, передача контроллеру, финальное ревью, PR 1 + +**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки по ревью — **sonnet**. + +- [ ] **Step 1: Полный набор на PG16.** `WAIT_LOCK`; `API_TEST tests`. Ожидается ровно 3 известных падения (Global Constraints), остальное PASS. +- [ ] **Step 2: Передать главному контроллеру** (не блокирует фазу 2). Записать в ledger раздел `## Передано контроллеру`: + - **«PG15: прогнать полный `pytest` ветки `feat/236-aurora-1843-svod2-server-paging` @ `` на `dfip-pg15:55434`»** — сюда входят `test_svod2_sql.py` (нормализаторы при локали контура) и миграция `0053`; + - **«Стенд, байт-в-байт (#17)»:** `GET /api/v1/svod/2/{GO,RF}/{year}` без параметров (admin и dfip, все и одна организация, `offset=0&limit=50`) до и после миграции, sha256 распакованных тел; Excel RF — значения ячеек через `openpyxl`. + + Ответ контроллера приходит в ledger или комментарием к PR. Красный PG15 или стенд даёт находку `O-n`, её правка идёт в ветку фазы 1 с каскадным rebase фаз 2–3. +- [ ] **Step 3: Финальное ревью ветки — opus.** + - Ревьюер получает diff `feat/220-aurora-1842-svod2-column-virtual...feat/236-aurora-1843-svod2-server-paging`, пути спеки и правило замка. + - Фокус: требования `summary2-api`, Review Focus 1–2, совместимость с PG15, дословность оракула, отсутствие перегрузок. + - Находки — `C-n`/`I-n`/`M-n`. +- [ ] **Step 4: Правки по ревью** (sonnet). Каждая находка закрывается статусом `fixed `, `deferred` или `rejected <причина>`. После правок — `WAIT_LOCK` и `API_TEST tests`, ожидаются те же 3 падения. +- [ ] **Step 5: Документы.** Дописать в `design.md` решения, принятые при исполнении (с № 35), и обновить таблицу `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 1`. +- [ ] **Step 6: Push и PR 1.** `git push -u origin feat/236-aurora-1843-svod2-server-paging`, затем: + ```bash + tea pulls create --base feat/220-aurora-1842-svod2-column-virtual --head feat/236-aurora-1843-svod2-server-paging \ + --title "AURORA-1843: свод 2 — серверные страницы, поиск, фильтры и варианты (1/3, сервер)" --description "$(cat <файл описания>)" + ``` + Описание (по-русски): + - `Refs #236` (часть 1/3; `Closes #236` — в PR 3), Jira AURORA-1843; + - что сделано; + - тесты: оракулы, эталон байт-в-байт, полный `pytest` на PG16 и 3 известных падения; + - «PG15 — проверит контроллер»; + - чек-лист ручной проверки: старый фронт работает, прежний ответ байт-в-байт, Excel RF; + - строка «Замер до/после — добавит контроллер после прогона на стенде»; + - «Rulings I made» (`R-n`); + - таблица находок с маркерами и статусами; + - последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + + Цифр замеров в PR нет. Не мержить. +- [ ] **Step 7: Без остановки — фаза 2.** + +--- + +# Фаза 2 — фронт (ветка `feat/236-aurora-1843-svod2-server-paging-web`, PR в `feat/236-aurora-1843-svod2-server-paging`) + +Начало фазы: `cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-web` от вершины фазы 1, затем `git push -u origin feat/236-aurora-1843-svod2-server-paging-web`. + +### Task 9: Проверка окна отступами виртуализатора (первая задача фазы, design #6) + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `web/src/pages/Svod2Page/svod2Virtualizer.test.js` + +**Interfaces:** +- Consumes: `@tanstack/virtual-core` 3.11.2 (`web/node_modules/@tanstack/virtual-core`), `material-react-table` 3.2.1 (`dist/index.esm.js`). +- Produces: вывод в отчёте задачи и ledger: «отступы работают» или «запасной вариант #6». + +- [ ] **Step 1: Написать тест** (`node:test`): + +```js +import { Virtualizer } from '@tanstack/virtual-core'; +test('paddingStart/paddingEnd ставят глобальную строку i на i*rowH, общий размер = total*rowH', () => { + const rowH = 37, total = 5563, windowStart = 300, windowEnd = 1200; + const v = new Virtualizer({ count: windowEnd - windowStart, getScrollElement: () => null, estimateSize: () => rowH, + paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH, + scrollToFn: () => {}, observeElementRect: () => {}, observeElementOffset: () => {} }); + const m = v.getMeasurements(); + assert.equal(m[0].start, windowStart * rowH); + assert.equal(m[m.length - 1].end, windowEnd * rowH); + assert.equal(v.getTotalSize(), total * rowH); +}); +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Virtualizer.test.js`. Ожидается PASS. Если FAIL — тест показал, что предпосылка неверна: это результат проверки, а не баг теста. +- [ ] **Step 3: Проверить MRT по коду** и записать номера строк в отчёт: + - высота `tbody` — `rowVirtualizer.getTotalSize()` (около `:2481`); + - строки сдвигаются на `translateY(virtualRow.start)` (около `:2409`); + - в `useMRT_RowVirtualizer` пользовательские `rowVirtualizerOptions` разворачиваются **после** значений по умолчанию, в том числе `measureElement`. +- [ ] **Step 4: Решение.** + - Всё подтвердилось → продолжать по Task 10–12 как написано. + - Иначе остановиться и вернуть отчёт контроллеру. Контроллер выбирает запасной вариант #6 (массив длины `total` с общей заглушкой), записывает его в `design.md` и правит Interfaces Task 10–12 до их запуска. +- [ ] **Step 5: Commit.** `test(web): свод 2 — окно строк через отступы виртуализатора`. + +### Task 10: `svod2Paging.js` — окно, срез с заглушками, LRU, параметры, варианты + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `web/src/pages/Svod2Page/svod2Paging.js` (чистый модуль: без React, JSX и axios) +- Create: `web/src/pages/Svod2Page/svod2Paging.test.js` + +**Interfaces:** +- Produces: + - `PAGE_SIZE = 300`, `WINDOW_RADIUS = 1`, `PAGE_CACHE_LIMIT = 5`, `FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']`, `EMPTY_FACET_MAP` (замороженный пустой `Map`); + - `getPageCount(total: number): number`; + - `getVisiblePage({ scrollTop, rowHeight, total }): number` — `floor(floor(scrollTop / rowHeight) / PAGE_SIZE)`, прижатый к `[0, max(pageCount − 1, 0)]`; + - `getWindowPages(visiblePage: number, total: number): number[]` — `visiblePage ± WINDOW_RADIUS` внутри `[0, pageCount)`, по возрастанию; при `total = 0` — `[]`; + - `getWindowRange(pages: number[], total: number): { windowStart: number, windowEnd: number }` (`[]` → `{0, 0}`); + - `buildWindowRows(pages: number[], pageCache: Map, total: number): object[]`. Для каждой страницы окна ровно `min(total, (p+1)·PAGE_SIZE) − p·PAGE_SIZE` элементов: строки из кэша, недостающие места — `{ __placeholder: true, index }` (глобальный индекс), лишние строки отбрасываются; + - `getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }): { paddingStart: number, paddingEnd: number }`; + - `touchPage(cache: Map, pageIndex: number, rows: object[], keepPages: number[] = []): Map` — новый `Map`, страница становится самой свежей; вытесняются самые старые не из `keepPages`, пока `size > PAGE_CACHE_LIMIT`; + - `getMissingPages(pages: number[], cache: Map, inflight: Set): number[]`; + - `buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] }): Record`: + - `org_ids` — через запятую, если массив не пуст; + - `search` как введён, если `!== ''`; + - для `{ id, value }` из `columnFilters` с `id` из `FILTER_COLUMNS` и значением не `''`/`null`/`undefined` — `[id]: String(value)`; + - `buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE })` — `{ view: 'compact', offset, limit, ...buildSvod2QueryParams(...) }`; + - `facetsToMaps(result): Map>` — новые объекты на каждый вызов; + - `shouldShowFound({ hasCurrentData, loading, facets }): boolean` — `hasCurrentData && !loading && !!facets && facets.search_total !== facets.total`; + - `isPlaceholderRow(row): boolean`; + - `getSvod2RowId(row): string` — `ph-${index}` у заглушки, иначе прежний id `${source_form}-${source_id}-${line_id}` (`Svod2Page.jsx:397`). + +- [ ] **Step 1: Написать падающие тесты:** + +```js +test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1])); +test('окно в середине и в конце', () => { assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]); assert.deepEqual(getWindowPages(18, 5563), [17, 18]); }); +test('total = 0 — пустое окно и пустой срез', () => { assert.deepEqual(getWindowPages(0, 0), []); assert.deepEqual(buildWindowRows([], new Map(), 0), []); }); +test('видимая страница прижимается к последней при уменьшении total', () => + assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2)); +test('срез: неполная последняя страница и заглушки', () => { + const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563); + assert.equal(rows.length, 300 + 163); + assert.deepEqual(rows[0], { __placeholder: true, index: 5100 }); + assert.equal(rows[300].id, 0); +}); +test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => { /* total изменился между запросами */ }); +test('сумма отступов и строк окна = total * rowH', () => { + const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563); + const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }); + assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37); +}); +test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => { /* 7 вставок, keepPages = [0, 1] */ }); +test('getMissingPages исключает загруженные и летящие', () => assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4])); +test('параметры страницы: compact, пустые не передаются, search как введён', () => { + assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), { view: 'compact', offset: 300, limit: 300 }); + assert.deepEqual(buildSvod2QueryParams({ orgIds: [3, 5], search: ' аренда ', columnFilters: [{ id: 'org_name', value: 'Филиал Ёлки' }, { id: 'name', value: 'x' }] }), + { org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' }); +}); +test('facetsToMaps', () => assert.equal(facetsToMaps({ smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] }).get('smeta_type').get('КВ'), 3)); +test('«Найдено» — только поиск', () => { + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false); +}); +test('getSvod2RowId', () => assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7')); +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Paging.test.js` — FAIL (модуля нет). +- [ ] **Step 3: Реализовать** модуль по Interfaces. +- [ ] **Step 4:** `WAIT_LOCK`; тесты PASS; `npx biome check src/pages/Svod2Page/svod2Paging.js src/pages/Svod2Page/svod2Paging.test.js` — без ошибок. +- [ ] **Step 5: Commit.** `perf(web): свод 2 — чистая логика окна страниц, параметров и вариантов`. + +### Task 11: Редьюсер состояния вкладки (generation, устаревшие ответы, ошибки) + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `web/src/pages/Svod2Page/svod2Paging.js` +- Modify: `web/src/pages/Svod2Page/svod2Paging.test.js` + +**Interfaces:** +- Consumes: `touchPage` (Task 10). +- Produces: + - `createSheetPagingState(): { generation: 0, dataGeneration: 0, pendingQueryKey: '', loadedQueryKey: '', total: 0, facets: null, pages: Map, loading: false, error: '' }`; + - `svod2PagingReducer(state, action)`. Действия с устаревшим `generation` (≠ `state.generation`) возвращают **тот же** объект `state`: + - `{ type: 'begin', generation, mode: 'load' | 'refine', queryKey }` — `generation` и `pendingQueryKey` обновляются, `error = ''`. При `load` — `loading = true`. При `refine` строки, `total` и `facets` остаются (решение #20); + - `{ type: 'page', generation, pageIndex, rows, total, keepPages }` — первый ответ поколения (`dataGeneration !== generation`) заменяет `pages` на `new Map([[pageIndex, rows]])` и ставит `dataGeneration = generation`, `loadedQueryKey = pendingQueryKey`. Следующие ответы идут через `touchPage`. `total = action.total`; + - `{ type: 'facets', generation, facets }` — `facets = action.facets`; + - `{ type: 'fail', generation, mode: 'load' | 'refine' | 'scroll', message }`: + - `load`/`refine` → `error = message`, `pages = new Map()`, `total = 0` (как `rows: []` при ошибке «до»); + - `scroll` → тот же объект `state`; уведомление показывает хук; + - `{ type: 'settled', generation }` → `loading = false`. + +- [ ] **Step 1: Написать падающие тесты:** + +```js +test('устаревшие page/facets/fail игнорируются', () => { + let s = svod2PagingReducer(createSheetPagingState(), { type: 'begin', generation: 1, mode: 'load', queryKey: 'k' }); + s = svod2PagingReducer(s, { type: 'begin', generation: 2, mode: 'refine', queryKey: 'k' }); + for (const a of [{ type: 'page', generation: 1, pageIndex: 0, rows: [], total: 9 }, { type: 'facets', generation: 1, facets: {} }, + { type: 'fail', generation: 1, mode: 'load', message: 'x' }]) assert.equal(svod2PagingReducer(s, a), s); +}); +test('refine оставляет прежние строки до первого ответа нового поколения', () => { /* pages поколения 1 видны после begin(2, refine); page(2) заменяет их */ }); +test('первый ответ поколения ставит loadedQueryKey', () => { /* begin(1, load, 'k') → page(1) → loadedQueryKey === 'k' */ }); +test('ошибка догрузки (scroll) не меняет состояние, страница снова среди недостающих', () => { + const s = /* состояние с pages {0}, окно [0, 1] */; + assert.equal(svod2PagingReducer(s, { type: 'fail', generation: s.generation, mode: 'scroll', message: 'x' }), s); + assert.deepEqual(getMissingPages([0, 1], s.pages, new Set()), [1]); +}); +test('ошибка load очищает строки и ставит error', () => { /* error === 'x', pages.size === 0 */ }); +test('последний total поколения побеждает', () => { /* page(0, total 5563) → page(1, total 5560) → total === 5560 */ }); +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `node --test src/pages/Svod2Page/svod2Paging.test.js` — новые тесты FAIL. +- [ ] **Step 3: Реализовать** `createSheetPagingState` и `svod2PagingReducer`. +- [ ] **Step 4:** `WAIT_LOCK`; все тесты файла PASS; biome по двум файлам чист. +- [ ] **Step 5: Commit.** `perf(web): свод 2 — состояние вкладки с поколениями запросов`. + +### Task 12: Интеграция в Svod2Page — API, хук, таблица, заглушки + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `web/src/api/summary.js` (`get` не трогать) +- Create: `web/src/pages/Svod2Page/useSvod2Pages.js` +- Modify: `web/src/pages/Svod2Page/Svod2Page.jsx` +- Modify: `web/src/pages/Svod2Page/svod2Styles.js` (правило заглушки в `SVOD2_TABLE_CONTAINER_SX`) +- Modify: `web/src/pages/Svod2Page/svod2Styles.test.js` + +**Interfaces:** +- Consumes: Task 10–11; API фазы 1 (`GET /svod/2/{sheet}/{year}?view=compact&offset&limit&…`, `GET /svod/2/{sheet}/{year}/facets`). +- Produces: + - `SummaryApi.getSvod2Page(sheet, year, params, { signal } = {})` → `api.get(\`/svod/2/${sheet}/${year}\`, { params, signal }).then((r) => r.data)`; + - `SummaryApi.getSvod2Facets(sheet, year, params, { signal } = {})` — то же для `/facets`; + - `useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop })` → `{ state, windowRows, padding: { paddingStart, paddingEnd }, facetsMap, load(mode: 'load' | 'refresh'), onScroll() }`: + - состояние по вкладкам: `{ GO, RF }` → `svod2PagingReducer`; + - на вкладку — одно поколение и `AbortController` на запросы поколения. Новое поколение отменяет запросы прежнего; отмена axios (`axios.isCancel`) не считается ошибкой; + - `load` → `begin(load)`, затем `facets` и `getMissingPages(окно от getScrollTop())`, `settled` по завершении всех; + - смена `[search, columnFilters]` при `hasCurrentData` → `begin(refine)` с теми же запросами; + - `onScroll` — через `requestAnimationFrame`: пересчёт видимой страницы и запрос недостающих страниц текущего поколения; + - ошибка страницы в режиме `scroll` → `toast.error('Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.')` из `react-toastify`; + - `inflight` очищается при любом исходе. + +**Изменения `Svod2Page.jsx`:** +- Убрать `getSummary`, `invalidateSummary`, `matchesSearch`, `filteredRows`, `tableStateBySheet`, `latestSummaryRequestRef`. `useSvod()` остаётся только ради `getSheetFilters`/`updateSheetFilters`. +- `hasCurrentData = state.loadedQueryKey === currentQueryKey`. Подсказка «Нажмите «Загрузить»…», `Alert` с «Повторить» (вызывает `load('refresh')`) и кнопки «Загрузить»/«Обновить» остаются прежними, с теми же подписями и условиями `disabled`. +- Таблица: + - `data: hasCurrentData ? windowRows : []`, `getRowId: getSvod2RowId`; + - `manualFiltering: true`, `enableFacetedValues: true`; + - `getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP`; `filterSelectOptions` **не задавать**; + - `rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }`; сам `ROW_VIRTUALIZER_OPTIONS` не менять; + - `muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }`, `getScrollTop` — из `table.refs.tableContainerRef.current?.scrollTop ?? 0`; + - `muiTableBodyRowProps: ({ row }) => isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}`. +- «Найдено» — `shouldShowFound(...)`, текст прежний: `Найдено ${facets.search_total.toLocaleString('ru-RU')} из ${facets.total.toLocaleString('ru-RU')} строк`. +- `updateCurrentSheetFilters` (сброс колоночных фильтров при смене поиска, года, организаций) не менять. Положение прокрутки программно не менять. + +- [ ] **Step 1: Написать падающий тест** в `svod2Styles.test.js`: + +```js +test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => { + const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td']; + assert.ok(rule, 'нет правила заглушки'); + assert.equal(rule.color, 'transparent'); +}); +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/web && node --test src/pages/Svod2Page/svod2Styles.test.js` — FAIL. +- [ ] **Step 3: Реализовать** `svod2Styles.js` (правило заглушки: `color: 'transparent'`, дочерним бейджам — прозрачные фон и рамку), `summary.js`, `useSvod2Pages.js`, `Svod2Page.jsx`. +- [ ] **Step 4: Проверки.** `WAIT_LOCK`, затем: + - `node --test src/pages/Svod2Page/*.test.js` — PASS; + - `npx biome check src/pages/Svod2Page src/api/summary.js` — без ошибок; + - `npm run build` — успешно; + - `git grep -n "getSummary\|invalidateSummary\|matchesSearch" -- web/src/pages/Svod2Page` — пусто. +- [ ] **Step 5: Commit.** Пять файлов; `perf(web): свод 2 — строки страницами с сервера, поиск, фильтры и варианты через API`. + +### Task 13: Завершение фазы 2 — ручная проверка контроллера, финальное ревью, PR 2 + +**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки — **sonnet**. + +- [ ] **Step 1: Полные проверки фронта.** `WAIT_LOCK`, затем `cd $WT/web && node --test src/components/RealtimeTable/utils/*.test.js src/pages/SvodPage/*.test.js src/pages/Svod2Page/*.test.js`, `npx biome check src/pages/Svod2Page src/api/summary.js`, `npm run build` — всё зелёное. +- [ ] **Step 2: Передать главному контроллеру ручную проверку на стенде** (#17, не блокирует фазу 3). В ledger, раздел `## Передано контроллеру`: «Фаза 2 @ ``: сравнение со сборкой "до" на тех же данных». Чек-лист — design, «Тестирование», фаза 2, руками: + - GO и RF, admin и dfip, «Все» и выбор организаций; + - поиск, три фильтра и их сочетания, варианты с числами, «Найдено», сброс фильтров; + - вкладки и положение прокрутки; + - ползунок в середину и в конец; + - быстрый ввод, затем фильтр; + - единицы, «Перейти к задачам»; + - горизонтальная прокрутка; + - ошибка догрузки; + - скриншоты. + + Расхождения возвращаются находками `O-n`. +- [ ] **Step 3: Финальное ревью ветки — opus.** + - Diff `feat/236-aurora-1843-svod2-server-paging...feat/236-aurora-1843-svod2-server-paging-web`. + - Фокус: требования `summary2-screen`, Review Focus 3–5, неизменность поведения (подписи, условия показа, сброс фильтров), утечки `AbortController`/`requestAnimationFrame`, отсутствие правок `ROW_VIRTUALIZER_OPTIONS`, `SvodContext` и `SvodFilters`. + - Нумерация `C/I/M` продолжает фазу 1. +- [ ] **Step 4: Правки по ревью** (sonnet), повтор Step 1. +- [ ] **Step 5: Документы.** Решения — в `design.md`, таблица `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 2`. +- [ ] **Step 6: Push и PR 2.** + ```bash + git push origin feat/236-aurora-1843-svod2-server-paging-web + tea pulls create --base feat/236-aurora-1843-svod2-server-paging --head feat/236-aurora-1843-svod2-server-paging-web \ + --title "AURORA-1843: свод 2 — догрузка строк страницами, поиск и фильтры через сервер (2/3, фронт)" --description "$(cat <файл>)" + ``` + Описание — по шаблону Task 8 Step 6: `Refs #236` (часть 2/3), чек-лист ручной проверки из Step 2, «Замер до/после — добавит контроллер после прогона на стенде», «Rulings I made», таблица находок. Цифр нет. Не мержить. +- [ ] **Step 7: Без остановки — фаза 3.** + +--- + +# Фаза 3 — стенд замеров (ветка `feat/236-aurora-1843-svod2-server-paging-perf`, PR в `feat/236-aurora-1843-svod2-server-paging-web`) + +Начало фазы: `cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-perf` от вершины фазы 2, затем `git push -u origin feat/236-aurora-1843-svod2-server-paging-perf`. Исполнители стенд не запускают (Global Constraints). + +### Task 14: Сверка на данных стенда — режим `--check` JS-оракула и `svod2_paging_check.py` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `perf/analysis/svod2_search_oracle.mjs` +- Modify: `perf/analysis/svod2_search_oracle.test.mjs` +- Create: `perf/analysis/svod2_paging_check.py` +- Create: `perf/tests/test_svod2_paging_check.py` + +**Interfaces:** +- Consumes: экспорты Task 1; `compact_projection` из `api/tests/svod2_oracle.py` — подключается через `importlib.util.spec_from_file_location` по пути от корня репозитория; формат манифеста стенда, как в `perf/analysis/api_split.py`. +- Produces: + - `deriveCases(fullRows): { search: string, filters: object }[]`. Запросы из спеки (`аренда`, `АРЕНДА`, `1`, `,`) плюс выведенные из данных: + - подстрока названия организации; + - подстрока контрагента `contract_detail`; + - первое дробное число из `contract_detail`/`header` в записи JS; + - первое `smeta_type` в нижнем регистре с пробелом в конце; + - первое `org_name`; + - их пары; + - `compareCase(expected: { line_ids, total, found, facets }, actual: { line_ids, total, found, facets }): string[]` — пустой массив, если совпало; + - `runCheck({ api, username, password, year, sheets = ['GO', 'RF'], pageSize = 300 }): Promise<{ ok: boolean, report: string[] }>`. Логин, прежний полный ответ, затем по каждому случаю все страницы `view=compact` и `/facets`, сравнение с `screenRows`/`screenFound`/`screenFacets`; + - CLI `node perf/analysis/svod2_search_oracle.mjs --check --api --username --password

--year ` — код выхода 1 при расхождении; + - `svod2_paging_check.py --api --scale ` (пользователь dfip и год — из `perf/results/manifest-s.json`). Чистые функции: + - `stitch_pages(fetch_page: Callable[[int, int], dict], limit: int) -> tuple[list[dict], set[int]]` — строки и множество `total`; + - `compare_stitched(full_rows: list[dict], stitched: list[dict]) -> list[str]`; + - `check_compact(full_rows: list[dict], compact_rows: list[dict]) -> list[str]`. + + Проверки: склейка `view=full limit=300` равна прежнему ответу (`data` целиком), `total` один на всех страницах, страница за концом пуста, compact — проекция full. Код выхода 1 при расхождении. + +- [ ] **Step 1: Написать падающие тесты.** + - Node, в `svod2_search_oracle.test.mjs`: + - `deriveCases` на `SYNTHETIC_ROWS` даёт не меньше 8 случаев, и в них есть дробное число; + - `compareCase` находит различие в порядке `line_ids` и в числе варианта; + - `runCheck` на локальном `http.createServer` (заглушка API: `/auth/login`, прежний ответ из `SYNTHETIC_ROWS`, страницы и `/facets`, посчитанные теми же `screen*`) возвращает `ok: true`. Если заглушка портит одну страницу, возвращается `ok: false`. + - pytest, в `test_svod2_paging_check.py`: + - `stitch_pages` на фейковом `fetch_page` из 7 строк при `limit=3` → 7 строк, `{7}`; + - `compare_stitched` ловит пропуск и повтор; + - `check_compact` ловит лишний ключ и потерянный `0`. +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`; `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py` — новые тесты FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4:** те же команды — PASS. Дословность из Task 1 Step 4 по-прежнему `MATCHES_OK`/`EQUALS_OK`. +- [ ] **Step 5: Commit.** `perf: свод 2 — сверка страниц, поиска и вариантов с экраном «до» на данных стенда`. + +### Task 15: `scroll.spec.ts` для свода 2 — заглушки, время до первых строк, байты + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `perf/e2e/lib/svod2.ts` (чистый, без импортов, стираемый TS) +- Create: `perf/e2e/tests/svod2-metrics.test.ts` (`node --test`; Playwright его не подхватывает, `testMatch` — `*.spec.ts`) +- Modify: `perf/e2e/scenarios/scroll.spec.ts` (только ветки `t.kind === 'svod2'`) + +**Interfaces:** +- Produces (`lib/svod2.ts`): + - `SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])'`, `SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]'`; + - `svod2RespRe(sheet: string): RegExp` — ответы `/api/v1/svod/2//` и `/api/v1/svod/2///facets`; + - `isSvod2SearchUrl(url: string): boolean` — запрос свода 2 с параметром `search=`; + - `class ByteCounter` — методы `add(requestId: string, bytes: number): void`, `markFirstRows(): void`, геттеры `sheet` (максимум одного ответа — прежний `json_bytes_sheet`), `total` (сумма), `first` (сумма до `markFirstRows`). Без parameter properties; + - `placeholderShare(samples: boolean[]): number` — доля кадров, где видна хотя бы одна заглушка; + - `countPagesRequested(urls: string[]): number` — число запросов страниц (`offset=` в query). + +**Изменения `scroll.spec.ts`** (design § 7): +- для свода 2 `waitRows` считает `SVOD2_DATA_ROW_SELECTOR`; +- `first_rows_ms` — от клика «Загрузить» до `MIN_ROWS` строк с данными; +- метрики `json_bytes_sheet` (прежний смысл), `json_bytes_total`, `json_bytes_first`, `placeholder_frame_share` (сэмпл в `requestAnimationFrame` во время прокрутки), `pages_requested`; +- тест фильтра: после `fill('1')` ждать ответ с `isSvod2SearchUrl` до 5 с (нет запроса — сборка «до», идти дальше), затем `awaitStable`, метрика `filter_result_ms`; +- клики `:194-200`, подписи и прочие `kind` не менять. Код работает и со сборкой «до»: селектор без заглушек совпадает с `tbody tr`. + +- [ ] **Step 1: Написать падающие тесты** `svod2-metrics.test.ts` (`import { … } from '../lib/svod2.ts'`): + - `ByteCounter`: три ответа 10/30/20, `markFirstRows` после второго → `sheet = 30`, `total = 60`, `first = 40`; + - `placeholderShare([true, false, false, false]) === 0.25`; + - `isSvod2SearchUrl('…/svod/2/GO/2026?view=compact&search=1') === true`, без `search` — `false`; + - `svod2RespRe('GO')` матчит `/facets` и страницу, но не `/svod/2/RF/`; + - `countPagesRequested`. +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts` — FAIL. +- [ ] **Step 3: Реализовать** `lib/svod2.ts` и правки `scroll.spec.ts` (импорт `'../lib/svod2'`). +- [ ] **Step 4:** `WAIT_LOCK`; тест PASS. Проверку типов и `npx playwright test --list` выполнит контроллер (записать в ledger `## Передано контроллеру`). +- [ ] **Step 5: Commit.** `perf: scroll.spec — свод 2 со страницами: заглушки, время до первых строк, суммарные байты`. + +### Task 16: Сценарий памяти `svod2-memory.spec.ts` + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `perf/e2e/scenarios/svod2-memory.spec.ts` +- Modify: `perf/e2e/lib/svod2.ts`, `perf/e2e/tests/svod2-metrics.test.ts` + +**Interfaces:** +- Consumes: `lib/cdp.ts` (`gc`, `heapUsed` `:45`, `domCounters`), `lib/auth.ts`, `lib/manifest.ts`, `lib/results.ts` — по образцу `scenarios/memory.spec.ts`; клики выбора «Все ССП/РФ» и «Загрузить» как в `scroll.spec.ts:194-200`. +- Produces: + - `memoryPoints(env: Record): string[]` в `lib/svod2.ts` — `['go', 'rf', 'final']`, при `PERF_SVOD2_MEM_SCROLL=1` — `['go', 'rf', 'rf_scrolled', 'final']`; + - сценарий: роль dfip, свежий контекст на каждый повтор (`PERF_REPEATS`, по умолчанию 3). В каждой точке `gc` → `heapUsed` и `domCounters`, запись `{ scenario: 'svod2-memory', target: 'svod2/', … metrics: { heap_used_mb, nodes, documents, js_event_listeners } }`. + +- [ ] **Step 1: Падающий тест** `memoryPoints` в `svod2-metrics.test.ts` (оба варианта env). +- [ ] **Step 2:** `WAIT_LOCK`; `node --test tests/svod2-metrics.test.ts` — FAIL. +- [ ] **Step 3: Реализовать** `memoryPoints` и сценарий. +- [ ] **Step 4:** `WAIT_LOCK`; тест PASS. `npx playwright test --list scenarios/svod2-memory.spec.ts` — передать контроллеру. +- [ ] **Step 5: Commit.** `perf: сценарий памяти свода 2 (GO «все» → RF «все» → GO)`. + +### Task 17: `api_split.py`, `explain_manual.py`, `scale_compare.py`, README + +**Модель исполнителя:** haiku (записи и команды заданы design § 7 и ниже). + +**Files:** +- Modify: `perf/analysis/api_split.py` (список `reqs`, `:45-54`) +- Modify: `perf/analysis/explain_manual.py` (`queries`, `:59-81`, и её вызов) +- Modify: `perf/analysis/scale_compare.py` (`:35-50`) +- Modify: `perf/README.md` +- Create: `perf/tests/test_svod2_analysis.py` + +**Interfaces:** +- Produces: + - в `api_split.py` — прежние строки плюс новые (роль `dfip`, `year` из манифеста): + - GO и RF `?view=full&offset=0&limit=300`; + - GO и RF `?view=compact&offset=0&limit=300`; + - GO и RF `/facets`; + - RF `?view=compact&offset=0&limit=300&search=1`; + - `explain_manual.queries(f2: int, f4: int, year: int) -> list[dict]` с новыми записями (у всех `"user": True`, варианты `[("", {}), (".jit_off", {"jit": "off"})]`): + - `svod2_go_page` — `SELECT * FROM v3.v_svod2_go({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)`; + - `svod2_rf_page` — то же для `v_svod2_rf`; + - `svod2_rf_search` — `v_svod2_rf` с `'1'` в `p_search`; + - `svod2_go_facets` — `SELECT * FROM v3.v_svod2_facets('GO', {year}, NULL, NULL, NULL, NULL, NULL)`; + - `scale_compare.svod2_key(path: str) -> str` (путь с query): + - `/api/v1/svod/2/GO/2026` → `"GET свод 2 GO"`; + - с `limit=` → `"GET свод 2 GO страница"`; + - `…/facets` → `"GET свод 2 GO facets"`. + + `elif` для свода 2 в цикле ключей использует `svod2_key`; + - README: раздел «Свод 2 после AURORA-1843»: + - команды из таблицы design «Замер до/после»; + - порядок «до/после» на одних данных; + - `--only`/`--repeats` для `perfkit.bench`; + - запуск `svod2_paging_check.py` и `svod2_search_oracle.mjs --check` перед замером «после». + +- [ ] **Step 1: Падающие тесты** `test_svod2_analysis.py` (импорт через `sys.path.insert(0, …/analysis)`, как в `test_analysis.py`): + +```python +def test_svod2_key(): + assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026") == "GET свод 2 GO" + assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница" + assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets" +def test_explain_has_svod2_queries(): + names = {q["name"] for q in explain_manual.queries(1, 2, 2026)} + assert {"svod2_go_page", "svod2_rf_page", "svod2_rf_search", "svod2_go_facets"} <= names +``` + +- [ ] **Step 2:** `WAIT_LOCK`; `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_analysis.py tests/test_analysis.py` — новые тесты FAIL. +- [ ] **Step 3: Реализовать.** +- [ ] **Step 4:** те же команды — PASS. +- [ ] **Step 5: Commit.** `perf: api_split, explain_manual и scale_compare для страниц и вариантов свода 2; README`. + +### Task 18: Завершение фазы 3 — финальное ревью, PR 3, точка ожидания замеров + +**Исполняет:** контроллер SDD; финальный ревьюер — **opus**; правки — **sonnet**. + +- [ ] **Step 1: Проверки без стенда.** `WAIT_LOCK`, затем: + - `cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs`; + - `cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts`; + - `cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py tests/test_svod2_analysis.py tests/test_analysis.py`. + + Всё должно быть PASS. +- [ ] **Step 2: Финальное ревью ветки — opus.** + - Diff `feat/236-aurora-1843-svod2-server-paging-web...feat/236-aurora-1843-svod2-server-paging-perf`. + - Фокус: сопоставимость «до/после» (код сценариев работает на сборке «до»), смысл `json_bytes_sheet` сохранён, ожидание ответа поиска, правило «стенд не трогать». + - Нумерация `C/I/M` продолжает фазу 2. +- [ ] **Step 3: Правки по ревью** (sonnet), повтор Step 1. +- [ ] **Step 4: Документы.** Решения — в `design.md`, таблица `## Findings` в ledger. Commit `docs(openspec): aurora-1843 — решения фазы 3`. +- [ ] **Step 5: Push и PR 3.** + ```bash + git push origin feat/236-aurora-1843-svod2-server-paging-perf + tea pulls create --base feat/236-aurora-1843-svod2-server-paging-web --head feat/236-aurora-1843-svod2-server-paging-perf \ + --title "AURORA-1843: свод 2 — сценарии и скрипты замера страниц (3/3, стенд)" --description "$(cat <файл>)" + ``` + Описание — по шаблону Task 8 Step 6, но с `Closes #236`. Добавить список команд замера из README и строку «Замер до/после — добавит контроллер после прогона на стенде». Цифр нет. Не мержить. +- [ ] **Step 6: Точка ожидания замеров контроллера.** + - Исполнение плана на этом заканчивается. Контроллер SDD отдаёт финальный отчёт: + - ссылки на три PR; + - раздел `## Передано контроллеру`: PG15, байт-в-байт и Excel на стенде, ручная проверка фазы 2, `playwright --list`/типы, замеры; + - таблица находок; + - «Rulings I made». + - Главный контроллер: + - сам делает reset стенда, но не во время чужих замеров; + - снимает «до» во временном worktree 1842 с подменой `perf/` из ветки фазы 3 и «после» на вершине фазы 3, на масштабах 1 и 2, по порядку design «Замер до/после»; + - перед «после» запускает `svod2_paging_check.py` и `svod2_search_oracle.mjs --check`; + - дописывает цифры в PR. + - Недобор целей оформляется follow-up и в таблице находок получает статус `deferred`. Красная сверка — блокер слияния, правка идёт в ветку своей фазы с каскадным rebase. diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index 0090955..b82dd9d 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -627,3 +627,49 @@ PR создаются по готовности, merge — по команде 22. **2026-10-28 — ~~ограничение длины `search` и значений фильтров — 1000 символов (422)~~. Отменено контроллером 28.10: противоречит решению человека 9 («поведение абсолютно такое же»).** Новая формулировка: API не ограничивает длину `search` и значений фильтров и не отвечает ошибкой из-за длины; длинный запрос даёт тот же результат, что на экране (совпадения есть или нет). Защита от злоупотребления — только невидимая пользователю: общий предел длины строки запроса у веб-сервера (uvicorn/h11 и прокси платформы, порядка килобайт и выше) остаётся как есть, отдельного предела в API не вводим. Цена ошибки: очень длинный URL (десятки КБ) может упереться в предел прокси до приложения — на экране это нереалистичный ввод; если понадобится, фронт переведёт запросы на POST — полдня. +23. **2026-10-28 (план) — JS-оракул `perf/analysis/svod2_search_oracle.mjs` пишется первой задачей фазы 1 и входит в PR 1, а не в PR 3. Режим сверки на стенде (`--check`) добавляется в фазе 3.** + Обоснование: требование человека — тесты-оракулы идут первыми и красными. Дословная копия правил экрана «до» должна существовать раньше SQL. + Цена ошибки: файл `perf/` в серверном PR — нет. +24. **2026-10-28 (план) — Python-оракул интеграционных тестов (`api/tests/svod2_oracle.py`) сверяется с векторами, которые порождает JS-оракул (`api/tests/fixtures/svod2_screen_vectors.json`, `js_number_text_vectors.json`).** + Обоснование: равенство Python-порта коду экрана доказывается без Node в CI. + Цена ошибки: при смене векторов — повторная генерация одной командой. +25. **2026-10-28 (план) — эталон прежнего ответа на засеянных данных (`api/tests/fixtures/svod2_legacy_golden.json`, sha256 тела) записывается один раз на коде «до» (Task 3) и после этого не перезаписывается.** + Обоснование: «байт-в-байт» проверяется в `pytest` на каждом прогоне, а не только сверкой на стенде. + Цена ошибки: при изменении засева эталон придётся переснимать на коде «до» — около 15 минут. +26. **2026-10-28 (план) — засев свода 2:** + - год 2031, явные id от 910001; + - скрипт выполняется в сессии фикстуры `client` (`client.db_session`) через `client.portal`; + - isp1 привязан к одной засеянной организации; + - добавлена четвёртая организация `«филиал ёлки »` (вариант названия по регистру и пробелу); + - добавлена строка без строки `contract_detail`. + + Обоснование: Review Focus плана — различимость вариантов фильтра и слияние пустой секции договора. + Цена ошибки: нет. +27. **2026-10-28 (план) — компактное `data` содержит все группы и подгруппы путей всегда, возможно пустыми (`{}`): это ровно результат `jsonb_strip_nulls(jsonb_build_object(...))`. Python-проекция `compact_projection` повторяет это.** + Обоснование: спецификация молчит о пустых группах. Экран читает пути через `?.`, отображение то же. + Цена ошибки: если нужен пропуск пустых групп — одна обёртка в SQL и в проекции. +28. **2026-10-28 (план) — `v3.svod2_lines` устроена как `WITH eligible_lines AS (…) SELECT … WHERE p_search IS NULL OR EXISTS (…)`. Тело отбора обрамлено комментариями-маркерами, по ним SQLite-тест `test_summary2_related_records_sql.py` извлекает отбор для GO и RF.** + Цена ошибки: если планировщик хуже встраивает CTE — переписать на подзапрос, тесты те же. +29. **2026-10-28 (план) — на фронте вся логика состояния вкладки — чистый редьюсер `svod2PagingReducer` в `svod2Paging.js` (покрыт `node --test`). Хук `useSvod2Pages.js` только ходит в API. `SummaryApi.getSvod2Page/getSvod2Facets` получают готовые параметры. `measureElement` переопределяется в компоненте константой `ROW_HEIGHT`, объект `ROW_VIRTUALIZER_OPTIONS` не меняется.** + Обоснование: хук без тестового рендерера не проверить; поколения и устаревшие ответы — главный риск поведения. + Цена ошибки: нет. +30. **2026-10-28 (план) — чистые помощники стенда для свода 2 вынесены в `perf/e2e/lib/svod2.ts`, их тест — `node --test` (Node 24 стирает типы). Проверку типов и `playwright test --list` делает контроллер.** + Обоснование: у исполнителей нет `perf/e2e/node_modules`, и стенд они не запускают. + Цена ошибки: ошибка типов в сценарии ловится контроллером перед замером — одна итерация. +31. **2026-10-28 (план) — PR 1 и PR 2 ссылаются на issue как `Refs #236` (часть 1/3, 2/3), PR 3 — `Closes #236`.** + Обоснование: стековые PR вливаются не в `test`, и issue не должна закрыться до последней части. + Цена ошибки: правка описания PR. +32. **2026-10-28 (план) — проверки, которые делает главный контроллер, не блокируют следующую фазу:** + - PG15; + - стенд байт-в-байт и Excel; + - ручная проверка фазы 2; + - `playwright --list`. + + Исполнение ждёт контроллера только в конце фазы 3 (замеры). Красный результат проверки становится находкой `O-n`, правка идёт в ветку своей фазы с каскадным rebase. + Обоснование: «между фазами без остановки», а слияние всё равно только по команде человека. + Цена ошибки: каскадный rebase — до часа. +33. **2026-10-28 (план) — нумерация `C/I/M` финальных ревью сквозная по трём веткам, номера задач плана сквозные (1–18).** + Обоснование: маркеры не меняются при переносе между PR и follow-up. + Цена ошибки: нет. +34. **2026-10-28 (план) — найденные странности (перекрытие полей поиска, поиск по техническим id) заносятся в таблицу находок сразу как `O-1` и `O-2` со статусом `deferred`. При закрытии они попадут в follow-up issue.** + Цена ошибки: нет. -- 2.47.2 From dbf90477d25843765eb9099a7e26da00e20ab74b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:08:18 +0400 Subject: [PATCH 05/17] =?UTF-8?q?docs(openspec):=20aurora-1843=20=E2=80=94?= =?UTF-8?q?=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20R-1..R?= =?UTF-8?q?-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/aurora-1843-svod2-server-paging/design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index b82dd9d..c180b18 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -673,3 +673,12 @@ PR создаются по готовности, merge — по команде Цена ошибки: нет. 34. **2026-10-28 (план) — найденные странности (перекрытие полей поиска, поиск по техническим id) заносятся в таблицу находок сразу как `O-1` и `O-2` со статусом `deferred`. При закрытии они попадут в follow-up issue.** Цена ошибки: нет. +35. **2026-10-28 (контроллер, R-1) — решения плана #23–#34 одобрены главным контроллером как есть.** + Цена ошибки: нет. +36. **2026-10-28 (контроллер, R-2) — хелпер `legacy_full` лежит в `api/tests/integration/conftest.py`, а не в `test_svod2_paging_api.py`. Task 3 создаёт, Task 4 и дальше импортируют оттуда.** + Обоснование: `test_svod2_sql.py` не должен импортировать из другого тестового модуля. + Цена ошибки: перенос функции. +37. **2026-10-28 (контроллер, R-3) — проверки разделены: PG15.12 выполняет контроллер SDD скриптом после снятия замка; сверку байт-в-байт на стенде, ручную проверку фазы 2, `playwright --list`/типы perf/e2e и замеры выполняет главный контроллер, исполнение их не ждёт.** + Цена ошибки: отложенное обнаружение, каскадный rebase. +38. **2026-10-28 (контроллер, R-4) — в коммитах исполнителей остаётся трейлер `Co-Authored-By: Claude Opus 5.5 ` из плана и правил волны 2.** + Цена ошибки: amend/rebase трейлера. -- 2.47.2 From adcb9d1d3b6ee7d3c1e34f939bc1e8d40ffed7d2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:09:29 +0400 Subject: [PATCH 06/17] =?UTF-8?q?test:=20=D1=81=D0=B2=D0=BE=D0=B4=202=20?= =?UTF-8?q?=E2=80=94=20JS-=D0=BE=D1=80=D0=B0=D0=BA=D1=83=D0=BB=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B0,=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=B2=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D0=BE=D0=B2=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=B4=D0=BE=C2=BB=20=D0=B8=20=D0=B2=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../fixtures/js_number_text_vectors.json | 86 ++ api/tests/fixtures/svod2_screen_vectors.json | 962 ++++++++++++++++++ perf/analysis/svod2_search_oracle.mjs | 149 +++ perf/analysis/svod2_search_oracle.test.mjs | 60 ++ 4 files changed, 1257 insertions(+) create mode 100644 api/tests/fixtures/js_number_text_vectors.json create mode 100644 api/tests/fixtures/svod2_screen_vectors.json create mode 100644 perf/analysis/svod2_search_oracle.mjs create mode 100644 perf/analysis/svod2_search_oracle.test.mjs diff --git a/api/tests/fixtures/js_number_text_vectors.json b/api/tests/fixtures/js_number_text_vectors.json new file mode 100644 index 0000000..def536a --- /dev/null +++ b/api/tests/fixtures/js_number_text_vectors.json @@ -0,0 +1,86 @@ +[ + { + "input": "0", + "js": "0" + }, + { + "input": "-0", + "js": "0" + }, + { + "input": "1", + "js": "1" + }, + { + "input": "-1", + "js": "-1" + }, + { + "input": "100", + "js": "100" + }, + { + "input": "100.00", + "js": "100" + }, + { + "input": "1234.50", + "js": "1234.5" + }, + { + "input": "12.34", + "js": "12.34" + }, + { + "input": "0.5", + "js": "0.5" + }, + { + "input": "0.30000000000000004", + "js": "0.30000000000000004" + }, + { + "input": "0.000001", + "js": "0.000001" + }, + { + "input": "1e-7", + "js": "1e-7" + }, + { + "input": "-1.5e-10", + "js": "-1.5e-10" + }, + { + "input": "123e-20", + "js": "1.23e-18" + }, + { + "input": "1e15", + "js": "1000000000000000" + }, + { + "input": "1e20", + "js": "100000000000000000000" + }, + { + "input": "1e21", + "js": "1e+21" + }, + { + "input": "123456789012345680000", + "js": "123456789012345680000" + }, + { + "input": "9007199254740993", + "js": "9007199254740992" + }, + { + "input": "5e-324", + "js": "5e-324" + }, + { + "input": "1.7976931348623157e308", + "js": "1.7976931348623157e+308" + } +] diff --git a/api/tests/fixtures/svod2_screen_vectors.json b/api/tests/fixtures/svod2_screen_vectors.json new file mode 100644 index 0000000..5f5987b --- /dev/null +++ b/api/tests/fixtures/svod2_screen_vectors.json @@ -0,0 +1,962 @@ +{ + "rows": [ + { + "data": { + "line_id": 1, + "header": { + "name": "Аренда помещения", + "smeta_type": "КВ", + "smeta_direction": "Развитие", + "org_name": "Филиал Ёлки" + }, + "contract_summary": { + "counterparty": "ООО Ромашка" + }, + "contract_detail": { + "counterparty": null, + "ceiling_amount": 1234.5 + } + } + }, + { + "data": { + "line_id": 2, + "header": { + "name": "АРЕНДА склада", + "smeta_type": "АХР", + "smeta_direction": "Поддержка", + "org_name": "Филиал Ёлки" + }, + "contract_summary": {}, + "contract_detail": { + "counterparty": "ООО Ромашка Плюс", + "ceiling_amount": 100 + } + } + }, + { + "data": { + "line_id": 3, + "header": { + "name": "Ёлка новогодняя", + "smeta_type": "КВ", + "smeta_direction": null, + "org_name": "филиал ёлки " + }, + "contract_summary": { + "counterparty": "ИП Ёж" + }, + "contract_detail": { + "ceiling_amount": 0 + } + } + }, + { + "data": { + "line_id": 4, + "header": { + "name": "ёжик", + "smeta_type": "кв ", + "smeta_direction": "Развитие", + "org_name": "Центр" + }, + "contract_summary": {}, + "contract_detail": { + "ceiling_amount": 1e-7 + } + } + }, + { + "data": { + "line_id": 5, + "header": { + "name": "Услуги связи", + "smeta_type": "КВ", + "smeta_direction": "Поддержка", + "org_name": "Центр" + }, + "contract_summary": {}, + "contract_detail": { + "note": "a b" + } + } + }, + { + "data": { + "line_id": 6, + "header": { + "name": "Без маркера", + "smeta_type": "АХР", + "smeta_direction": "Развитие", + "org_name": "Север" + }, + "contract_summary": {}, + "contract_detail": { + "note": "xy" + } + } + }, + { + "data": { + "line_id": 7, + "header": { + "name": "Флаг", + "smeta_type": "КВ", + "smeta_direction": "Развитие", + "org_name": "Север", + "is_active": true + }, + "contract_summary": { + "flag": true + }, + "contract_detail": { + "note": null + } + } + }, + { + "data": { + "line_id": 8, + "header": { + "name": null, + "smeta_type": null, + "smeta_direction": null, + "org_name": null + }, + "contract_summary": {}, + "contract_detail": {} + } + }, + { + "data": { + "line_id": 9, + "header": { + "name": "Итого 1", + "smeta_type": "АХР", + "smeta_direction": "Поддержка", + "org_name": "Центр" + }, + "contract_summary": { + "counterparty": "ООО Ромашка" + }, + "contract_detail": { + "counterparty": "ООО Ромашка", + "ceiling_amount": 1234.5 + } + } + }, + { + "data": { + "line_id": 10, + "header": { + "name": "Прочее", + "smeta_type": "ПР", + "smeta_direction": "Развитие", + "org_name": "Филиал Ёлки" + }, + "contract_summary": {}, + "contract_detail": { + "ceiling_amount": 1e-7, + "note": "Договор №1" + } + } + }, + { + "data": { + "line_id": 11, + "header": { + "name": "Лицензии", + "smeta_type": "КВ", + "smeta_direction": "Поддержка", + "org_name": "Юг" + }, + "contract_summary": {}, + "contract_detail": { + "ceiling_amount": 100 + } + } + } + ], + "cases": [ + { + "search": "", + "filters": {}, + "line_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 3 + ], + [ + "КВ", + 5 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 4 + ], + [ + "Развитие", + 5 + ] + ], + "org_name": [ + [ + "Север", + 2 + ], + [ + "Филиал Ёлки", + 3 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 3 + ], + [ + "Юг", + 1 + ] + ] + } + }, + { + "search": "аРЕНДА", + "filters": {}, + "line_ids": [ + 1, + 2 + ], + "found": { + "found": 2, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 2, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 1 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 2 + ] + ] + } + }, + { + "search": "ё", + "filters": {}, + "line_ids": [ + 1, + 2, + 3, + 4, + 10 + ], + "found": { + "found": 5, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 5, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 2 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 3 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 3 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 1 + ] + ] + } + }, + { + "search": "ромаш", + "filters": {}, + "line_ids": [ + 2, + 9 + ], + "found": { + "found": 2, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 2, + "smeta_type": [ + [ + "АХР", + 2 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 2 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 1 + ], + [ + "Центр", + 1 + ] + ] + } + }, + { + "search": "1234.5", + "filters": {}, + "line_ids": [ + 1, + 9 + ], + "found": { + "found": 2, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 2, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 1 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 1 + ], + [ + "Центр", + 1 + ] + ] + } + }, + { + "search": "1234.50", + "filters": {}, + "line_ids": [], + "found": { + "found": 0, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 0, + "smeta_type": [], + "smeta_direction": [], + "org_name": [] + } + }, + { + "search": " аренда ", + "filters": {}, + "line_ids": [ + 1, + 2 + ], + "found": { + "found": 2, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 2, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 1 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 2 + ] + ] + } + }, + { + "search": " ", + "filters": {}, + "line_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 3 + ], + [ + "КВ", + 5 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 4 + ], + [ + "Развитие", + 5 + ] + ], + "org_name": [ + [ + "Север", + 2 + ], + [ + "Филиал Ёлки", + 3 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 3 + ], + [ + "Юг", + 1 + ] + ] + } + }, + { + "search": "1", + "filters": {}, + "line_ids": [ + 1, + 2, + 4, + 9, + 10, + 11 + ], + "found": { + "found": 6, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 6, + "smeta_type": [ + [ + "АХР", + 2 + ], + [ + "КВ", + 2 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 3 + ], + [ + "Развитие", + 3 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 3 + ], + [ + "Центр", + 2 + ], + [ + "Юг", + 1 + ] + ] + } + }, + { + "search": "", + "filters": { + "smeta_type": "кв " + }, + "line_ids": [ + 1, + 3, + 4, + 5, + 7, + 11 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 3 + ], + [ + "КВ", + 5 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 2 + ], + [ + "Развитие", + 3 + ] + ], + "org_name": [ + [ + "Север", + 1 + ], + [ + "Филиал Ёлки", + 1 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 2 + ], + [ + "Юг", + 1 + ] + ] + } + }, + { + "search": "", + "filters": { + "org_name": "Филиал Ёлки" + }, + "line_ids": [ + 1, + 2, + 3, + 10 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 2 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 2 + ] + ], + "org_name": [ + [ + "Север", + 2 + ], + [ + "Филиал Ёлки", + 3 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 3 + ], + [ + "Юг", + 1 + ] + ] + } + }, + { + "search": "", + "filters": { + "smeta_type": "КВ", + "smeta_direction": "Развитие" + }, + "line_ids": [ + 1, + 4, + 7 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 2 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 2 + ], + [ + "Развитие", + 3 + ] + ], + "org_name": [ + [ + "Север", + 1 + ], + [ + "Филиал Ёлки", + 1 + ], + [ + "Центр", + 1 + ] + ] + } + }, + { + "search": "ё", + "filters": { + "smeta_type": "КВ" + }, + "line_ids": [ + 1, + 3, + 4 + ], + "found": { + "found": 5, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 5, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 2 + ], + [ + "кв ", + 1 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Развитие", + 2 + ] + ], + "org_name": [ + [ + "Филиал Ёлки", + 1 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 1 + ] + ] + } + }, + { + "search": "", + "filters": { + "org_name": "филиал ёлки" + }, + "line_ids": [ + 1, + 2, + 3, + 10 + ], + "found": { + "found": 11, + "total": 11 + }, + "facets": { + "total": 11, + "search_total": 11, + "smeta_type": [ + [ + "АХР", + 1 + ], + [ + "КВ", + 2 + ], + [ + "ПР", + 1 + ] + ], + "smeta_direction": [ + [ + "Поддержка", + 1 + ], + [ + "Развитие", + 2 + ] + ], + "org_name": [ + [ + "Север", + 2 + ], + [ + "Филиал Ёлки", + 3 + ], + [ + "филиал ёлки ", + 1 + ], + [ + "Центр", + 3 + ], + [ + "Юг", + 1 + ] + ] + } + } + ] +} diff --git a/perf/analysis/svod2_search_oracle.mjs b/perf/analysis/svod2_search_oracle.mjs new file mode 100644 index 0000000..2343a53 --- /dev/null +++ b/perf/analysis/svod2_search_oracle.mjs @@ -0,0 +1,149 @@ +// Оракул экрана «до» свода 2 (Svod2Page на коммите 399234a + material-react-table). Копии дословные. +// matchesSearch: Svod2Page.jsx:283-294; equals: material-react-table/dist/index.esm.js:663-667. +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const matchesSearch = (row, query) => { + const searchableData = { + ...row.data?.header, + ...row.data?.contract_summary, + ...row.data?.contract_detail, + }; + return Object.values(searchableData).some((value) => + String(value ?? '') + .toLowerCase() + .includes(query), + ); +}; + +const equals = (row, id, filterValue) => { + var _a; + return ((_a = row.getValue(id)) === null || _a === void 0 ? void 0 : _a.toString().toLowerCase().trim()) === + filterValue.toString().toLowerCase().trim(); +}; +equals.autoRemove = (val) => !val; + +export { matchesSearch, equals }; + +export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']; + +// accessorFn колонок: valueAt(row, ['header', id]) +const asTableRow = (row, id) => ({ getValue: () => row.data?.header?.[id] }); +const cellValue = (row, id) => row.data?.header?.[id]; + +const activeFilters = (filters, skipId) => + FILTER_COLUMNS.filter((id) => id !== skipId && filters[id] && !equals.autoRemove(filters[id])); + +const bySearch = (rows, search) => { + const normalizedSearch = String(search ?? '').trim().toLowerCase(); + return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; +}; + +const byFilters = (rows, filters, skipId) => { + const ids = activeFilters(filters, skipId); + return rows.filter((row) => ids.every((id) => equals({ getValue: () => cellValue(row, id) }, id, filters[id]))); +}; + +export function screenRows(rows, { search = '', filters = {} } = {}) { + return byFilters(bySearch(rows, search), filters, null); +} + +export function screenFound(rows, search) { + return { found: bySearch(rows, search).length, total: rows.length }; +} + +export function screenFacets(rows, { search = '', filters = {} } = {}) { + const searched = bySearch(rows, search); + const result = { total: rows.length, search_total: searched.length }; + for (const id of FILTER_COLUMNS) { + const counts = new Map(); + for (const row of byFilters(searched, filters, id)) { + const value = asTableRow(row, id).getValue(id); + counts.set(value, (counts.get(value) ?? 0) + 1); + } + result[id] = Array.from(counts.keys()) + .filter((value) => value !== null && value !== undefined) + .sort((a, b) => a.localeCompare(b)) + .map((value) => [value, counts.get(value)]); + } + return result; +} + +export const NUMBER_VECTOR_INPUTS = [ + '0', '-0', '1', '-1', '100', '100.00', '1234.50', '12.34', '0.5', '0.30000000000000004', + '0.000001', '1e-7', '-1.5e-10', '123e-20', '1e15', '1e20', '1e21', '123456789012345680000', + '9007199254740993', '5e-324', '1.7976931348623157e308', +]; + +// Числа в строках берём через JSON.parse, чтобы запись была как у JSON-ответа API. +const J = (text) => JSON.parse(text); + +export const SYNTHETIC_ROWS = [ + { data: { line_id: 1, header: { name: 'Аренда помещения', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' }, + contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null, ceiling_amount: J('1234.50') } } }, + { data: { line_id: 2, header: { name: 'АРЕНДА склада', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Филиал Ёлки' }, + contract_summary: {}, contract_detail: { counterparty: 'ООО Ромашка Плюс', ceiling_amount: J('100') } } }, + { data: { line_id: 3, header: { name: 'Ёлка новогодняя', smeta_type: 'КВ', smeta_direction: null, org_name: 'филиал ёлки ' }, + contract_summary: { counterparty: 'ИП Ёж' }, contract_detail: { ceiling_amount: J('0') } } }, + { data: { line_id: 4, header: { name: 'ёжик', smeta_type: 'кв ', smeta_direction: 'Развитие', org_name: 'Центр' }, + contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7') } } }, + { data: { line_id: 5, header: { name: 'Услуги связи', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Центр' }, + contract_summary: {}, contract_detail: { note: 'a b' } } }, + { data: { line_id: 6, header: { name: 'Без маркера', smeta_type: 'АХР', smeta_direction: 'Развитие', org_name: 'Север' }, + contract_summary: {}, contract_detail: { note: 'xy' } } }, + { data: { line_id: 7, header: { name: 'Флаг', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Север', is_active: true }, + contract_summary: { flag: true }, contract_detail: { note: null } } }, + { data: { line_id: 8, header: { name: null, smeta_type: null, smeta_direction: null, org_name: null }, + contract_summary: {}, contract_detail: {} } }, + { data: { line_id: 9, header: { name: 'Итого 1', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Центр' }, + contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: 'ООО Ромашка', ceiling_amount: J('1234.5') } } }, + { data: { line_id: 10, header: { name: 'Прочее', smeta_type: 'ПР', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' }, + contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7'), note: 'Договор №1' } } }, + { data: { line_id: 11, header: { name: 'Лицензии', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Юг' }, + contract_summary: {}, contract_detail: { ceiling_amount: J('100.00') } } }, +]; + +export const SCREEN_CASES = [ + { search: '', filters: {} }, + { search: 'аРЕНДА', filters: {} }, + { search: 'ё', filters: {} }, + { search: 'ромаш', filters: {} }, + { search: '1234.5', filters: {} }, + { search: '1234.50', filters: {} }, + { search: ' аренда ', filters: {} }, + { search: ' ', filters: {} }, + { search: '1', filters: {} }, + { search: '', filters: { smeta_type: 'кв ' } }, + { search: '', filters: { org_name: 'Филиал Ёлки' } }, + { search: '', filters: { smeta_type: 'КВ', smeta_direction: 'Развитие' } }, + { search: 'ё', filters: { smeta_type: 'КВ' } }, + { search: '', filters: { org_name: 'филиал ёлки' } }, +]; + +export function emitNumberVectors() { + return NUMBER_VECTOR_INPUTS.map((input) => ({ input, js: String(JSON.parse(input)) })); +} + +export function emitScreenVectors() { + return { + rows: SYNTHETIC_ROWS, + cases: SCREEN_CASES.map(({ search, filters }) => ({ + search, + filters, + line_ids: screenRows(SYNTHETIC_ROWS, { search, filters }).map((r) => r.data.line_id), + found: screenFound(SYNTHETIC_ROWS, search), + facets: screenFacets(SYNTHETIC_ROWS, { search, filters }), + })), + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const i = process.argv.indexOf('--emit-vectors'); + if (i > 0 && process.argv[i + 1]) { + const dir = process.argv[i + 1]; + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'js_number_text_vectors.json'), `${JSON.stringify(emitNumberVectors(), null, 2)}\n`); + writeFileSync(path.join(dir, 'svod2_screen_vectors.json'), `${JSON.stringify(emitScreenVectors(), null, 2)}\n`); + } +} diff --git a/perf/analysis/svod2_search_oracle.test.mjs b/perf/analysis/svod2_search_oracle.test.mjs new file mode 100644 index 0000000..749a8a5 --- /dev/null +++ b/perf/analysis/svod2_search_oracle.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + emitNumberVectors, + emitScreenVectors, + equals, + matchesSearch, + screenFacets, + screenFound, + screenRows, +} from './svod2_search_oracle.mjs'; + +const mk = (id, type, dir, org) => ({ data: { line_id: id, header: { smeta_type: type, smeta_direction: dir, org_name: org } } }); +const rows2 = [mk(1, 'КВ', 'Развитие', 'А'), mk(2, 'АХР', 'Поддержка', 'Б')]; +const rows3 = [mk(1, 'КВ', 'Р', 'Филиал Ёлки'), mk(2, 'КВ', 'Р', 'Филиал Ёлки'), mk(3, 'КВ', 'Р', null)]; + +test('перекрытое поле contract_summary не ищется', () => { + const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } }; + assert.equal(matchesSearch(row, 'ромаш'), false); +}); +test('число ищется в записи JS', () => { + const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } }; + assert.equal(matchesSearch(row, '1234.5'), true); + assert.equal(matchesSearch(row, '1234.50'), false); +}); +test('screenRows: trim + lower, NBSP по краям', () => { + const rows = [{ data: { line_id: 1, header: { name: 'Аренда помещения' } } }]; + assert.deepEqual(screenRows(rows, { search: ' АРЕНДА ' }).map((r) => r.data.line_id), [1]); + assert.deepEqual(screenRows(rows, { search: ' аренда ' }).map((r) => r.data.line_id), [1]); +}); +test('equals: регистр и пробелы по краям, null не проходит', () => { + const r = (v) => ({ getValue: () => v }); + assert.equal(equals(r('КВ'), 'smeta_type', 'кв '), true); + assert.equal(equals(r(null), 'smeta_type', 'кв'), false); +}); +test('screenFacets: свой фильтр не применяется, чужие — да', () => { + const f = screenFacets(rows2, { filters: { smeta_type: 'КВ' } }); + assert.deepEqual(f.smeta_type, [['АХР', 1], ['КВ', 1]]); + assert.deepEqual(f.smeta_direction, [['Развитие', 1]]); +}); +test('screenFacets: одинаковые названия — один вариант, null не выдаётся', () => { + assert.deepEqual(screenFacets(rows3).org_name, [['Филиал Ёлки', 2]]); +}); +test('screenFound считает только поиск', () => { + assert.deepEqual(screenFound(rows2, ''), { found: 2, total: 2 }); +}); +test('векторы чисел', () => { + const v = Object.fromEntries(emitNumberVectors().map(({ input, js }) => [input, js])); + assert.equal(v['1e21'], '1e+21'); + assert.equal(v['-0'], '0'); + assert.equal(v['1e-7'], '1e-7'); + assert.equal(v['0.000001'], '0.000001'); + assert.equal(v['100.00'], '100'); + assert.equal(v['9007199254740993'], '9007199254740992'); +}); +test('векторы экрана: не меньше 12 случаев', () => { + const s = emitScreenVectors(); + assert.ok(s.cases.length >= 12); + assert.ok(s.rows.length >= 10 && s.rows.length <= 15); +}); -- 2.47.2 From 174ec73732a8ee1650cefdfdfe22501168893495 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:11:17 +0400 Subject: [PATCH 07/17] =?UTF-8?q?test(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20Python-=D0=BE=D1=80=D0=B0=D0=BA=D1=83=D0=BB=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0,=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=20=D0=B2=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B0=D0=BC=D0=B8=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/svod2_oracle.py | 177 ++++++++++++++++++++++++++++ api/tests/unit/test_svod2_oracle.py | 51 ++++++++ 2 files changed, 228 insertions(+) create mode 100644 api/tests/svod2_oracle.py create mode 100644 api/tests/unit/test_svod2_oracle.py diff --git a/api/tests/svod2_oracle.py b/api/tests/svod2_oracle.py new file mode 100644 index 0000000..176cfc8 --- /dev/null +++ b/api/tests/svod2_oracle.py @@ -0,0 +1,177 @@ +"""Python-порт оракула экрана «до» свода 2 (perf/analysis/svod2_search_oracle.mjs). + +Поведение сверяется с векторами, снятыми в Node.js (tests/fixtures/*.json). +""" + +from __future__ import annotations + +import math +from decimal import Decimal + +FILTER_COLUMNS = ("smeta_type", "smeta_direction", "org_name") + +# ECMAScript WhiteSpace + LineTerminator (design § 3). +_JS_WHITESPACE = ( + "\u0009\u000a\u000b\u000c\u000d   " + "           " + "

   " +) + + +def js_trim(s: str) -> str: + return s.strip(_JS_WHITESPACE) + + +def js_number_text(x: int | float) -> str: + """ECMAScript Number::toString (radix 10).""" + f = float(x) + if math.isnan(f): + return "NaN" + if f == 0: + return "0" + if math.isinf(f): + return "Infinity" if f > 0 else "-Infinity" + sign = "-" if f < 0 else "" + # Кратчайшая запись цифр: repr(float) -> Decimal -> (digits, exponent). + t = Decimal(repr(abs(f))).as_tuple() + digits = "".join(map(str, t.digits)).rstrip("0") + # значение = 0.d1d2...dk * 10^n + n = len(t.digits) + t.exponent + k = len(digits) + if not digits: + return "0" + # убрать ведущие нули (Decimal('0.001') даёт digits (1,), без лишних; страховка) + stripped = digits.lstrip("0") + n -= len(digits) - len(stripped) + digits, k = stripped, len(stripped) + if k <= n <= 21: + body = digits + "0" * (n - k) + elif 0 < n <= 21: + body = digits[:n] + "." + digits[n:] + elif -6 < n <= 0: + body = "0." + "0" * (-n) + digits + else: + e = n - 1 + es = ("+" if e >= 0 else "-") + str(abs(e)) + body = (digits[0] + ("." + digits[1:] if k > 1 else "")) + "e" + es + return sign + body + + +def js_string(value: object) -> str: + """String(value ?? '').""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return js_number_text(value) + return str(value) + + +def matches_search(row: dict, query: str) -> bool: + data = row.get("data") or {} + merged = { + **(data.get("header") or {}), + **(data.get("contract_summary") or {}), + **(data.get("contract_detail") or {}), + } + return any(query in js_string(v).lower() for v in merged.values()) + + +def _by_search(rows: list[dict], search: str) -> list[dict]: + q = js_trim(search or "").lower() + return [r for r in rows if matches_search(r, q)] if q else rows + + +def _cell(row: dict, col: str): + return ((row.get("data") or {}).get("header") or {}).get(col) + + +def _equals(cell, filter_value: str) -> bool: + if cell is None: + return False + return js_trim(js_string(cell).lower()) == js_trim(filter_value.lower()) + + +def _by_filters(rows: list[dict], filters: dict[str, str], skip: str | None) -> list[dict]: + active = [c for c in FILTER_COLUMNS if c != skip and filters.get(c)] + return [r for r in rows if all(_equals(_cell(r, c), filters[c]) for c in active)] + + +def screen_rows(rows, search: str = "", filters=None) -> list[dict]: + return _by_filters(_by_search(rows, search), filters or {}, None) + + +def screen_found(rows, search: str) -> tuple[int, int]: + return len(_by_search(rows, search)), len(rows) + + +def screen_facets(rows, search: str = "", filters=None) -> dict: + filters = filters or {} + searched = _by_search(rows, search) + result: dict = {"total": len(rows), "search_total": len(searched)} + for col in FILTER_COLUMNS: + counts: dict = {} + for r in _by_filters(searched, filters, col): + v = _cell(r, col) + if v is not None: + counts[v] = counts.get(v, 0) + 1 + result[col] = dict(sorted(counts.items())) + return result + + +_Q = ("adj_current", "adj_ssp", "adj_rf", "adj_reserve", "payment_date", "payment_amount", + "payment_amount_ho", "payment_amount_rf", "payment_act", "booking_amount", + "actual_m1", "actual_m2", "actual_m3") + + +def _build_paths() -> tuple[str, ...]: + p = ["line_id"] + p += [f"header.{f}" for f in ("source_form", "source_id", "smeta_type", "smeta_direction", + "org_name", "name", "item_id", "num_group", "justification")] + p += [f"plan.plan_q{i}" for i in range(1, 5)] + p += ["allocation.internal_order", "allocation.property_object"] + p += [f"sequestration.DFIP.adj_q{i}" for i in range(1, 5)] + p += [f"reserve.amount_q{i}" for i in range(1, 5)] + p += [f"approved.approved_q{i}" for i in range(1, 5)] + p += [f"contract_detail.{f}" for f in ("counterparty", "reference", "subject", "currency", + "ceiling_amount")] + p += [f"contract_detail.expenses_q{i}" for i in range(1, 5)] + p += ["contract_detail.vat_rate", "contract_detail.deadline"] + for q in range(1, 5): + p += [f"q{q}.{f}" for f in _Q] + if q == 4: + p.append("q4.actual_spod") + if q <= 3: + p += [f"q{q}.transfer_to_q{k}" for k in range(q + 1, 5)] + p.append(f"q{q}.transfer_to_economy") + if q >= 2: + p += [f"q{q}.{f}" for f in ("plan_revision_eco_change", "plan_revision_item_adj", + "plan_revision_increase", "plan_revision_sequester")] + return tuple(p) + + +COMPACT_PATHS: tuple[str, ...] = _build_paths() + + +def compact_projection(data: dict) -> dict: + """jsonb_strip_nulls(jsonb_build_object(...)) по COMPACT_PATHS. + + Группы и подгруппы присутствуют всегда; листья None/отсутствующие опускаются. + """ + out: dict = {} + for path in COMPACT_PATHS: + parts = path.split(".") + src = data + for part in parts[:-1]: + src = src.get(part) if isinstance(src, dict) else None + dst = out + for part in parts[:-1]: + dst = dst.setdefault(part, {}) + leaf = src.get(parts[-1]) if isinstance(src, dict) else None + if len(parts) == 1: + if leaf is not None: + out[parts[0]] = leaf + elif leaf is not None: + dst[parts[-1]] = leaf + return out diff --git a/api/tests/unit/test_svod2_oracle.py b/api/tests/unit/test_svod2_oracle.py new file mode 100644 index 0000000..19422e5 --- /dev/null +++ b/api/tests/unit/test_svod2_oracle.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path + +import pytest + +from tests.svod2_oracle import ( + COMPACT_PATHS, + FILTER_COLUMNS, + compact_projection, + js_number_text, + screen_facets, + screen_found, + screen_rows, +) + +FIX = Path(__file__).resolve().parents[1] / "fixtures" +NUM = json.loads((FIX / "js_number_text_vectors.json").read_text()) +SCREEN = json.loads((FIX / "svod2_screen_vectors.json").read_text()) + + +def test_js_number_text_matches_node_vectors(): + for v in NUM: + assert js_number_text(json.loads(v["input"])) == v["js"], v + + +@pytest.mark.parametrize("case", SCREEN["cases"], ids=lambda c: f"{c['search']!r}-{c['filters']}") +def test_screen_port_matches_node(case): + rows = SCREEN["rows"] + assert [r["data"]["line_id"] for r in screen_rows(rows, case["search"], case["filters"])] == case["line_ids"] + assert screen_found(rows, case["search"]) == (case["found"]["found"], case["found"]["total"]) + f = screen_facets(rows, case["search"], case["filters"]) + assert (f["total"], f["search_total"]) == (case["facets"]["total"], case["facets"]["search_total"]) + for col in FILTER_COLUMNS: + assert f[col] == dict(case["facets"][col]) + + +def test_compact_projection_keeps_zero_drops_null_and_unknown(): + out = compact_projection({"line_id": 1, "header": {"source_form": "FORM_1", "name": None, "vsp_id": 5}, + "plan": {"plan_q1": 0, "plan_q2": None}}) + assert out["line_id"] == 1 + assert out["header"] == {"source_form": "FORM_1"} + assert out["plan"] == {"plan_q1": 0} + assert out["sequestration"] == {"DFIP": {}} + assert "transfer_to_q1" not in out["q1"] and set(out) >= {"q1", "q2", "q3", "q4", "contract_detail"} + + +def test_compact_paths_shape(): + assert len(COMPACT_PATHS) == len(set(COMPACT_PATHS)) + assert "q4.actual_spod" in COMPACT_PATHS and "q1.actual_spod" not in COMPACT_PATHS + assert "q1.transfer_to_q2" in COMPACT_PATHS and "q3.transfer_to_q4" in COMPACT_PATHS + assert "q4.transfer_to_economy" not in COMPACT_PATHS and "q1.plan_revision_increase" not in COMPACT_PATHS -- 2.47.2 From b134cfd118fd4ec1f2d75b27322755fa86b197ae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:19:22 +0400 Subject: [PATCH 08/17] =?UTF-8?q?test(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=B7=D0=B0=D1=81=D0=B5=D0=B2,=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=BD=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B-=D0=BE=D1=80=D0=B0=D0=BA=D1=83?= =?UTF-8?q?=D0=BB=D1=8B=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20(=D0=BA=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/tests/fixtures/svod2_legacy_golden.json | 13254 ++++++++++++++++ api/tests/integration/conftest.py | 53 + .../integration/test_svod2_legacy_golden.py | 70 + .../integration/test_svod2_paging_api.py | 260 + api/tests/sqls/svod2_seed.sql | 57 + 5 files changed, 13694 insertions(+) create mode 100644 api/tests/fixtures/svod2_legacy_golden.json create mode 100644 api/tests/integration/test_svod2_legacy_golden.py create mode 100644 api/tests/integration/test_svod2_paging_api.py create mode 100644 api/tests/sqls/svod2_seed.sql diff --git a/api/tests/fixtures/svod2_legacy_golden.json b/api/tests/fixtures/svod2_legacy_golden.json new file mode 100644 index 0000000..9ec9b9a --- /dev/null +++ b/api/tests/fixtures/svod2_legacy_golden.json @@ -0,0 +1,13254 @@ +{ + "GO-admin_all": { + "sha256": "da542804214418d8d12a007d1a36a536f269730f986cd9592df1774fe9d00f5b", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 20, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 30, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 100.0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 100.0 + }, + "header": { + "name": "Аренда помещения", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910001, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 100.0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 100.0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-1", + "expenses_q1": 20, + "expenses_q2": 30, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 1234.5, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-1", + "counterparty": "ООО Ромашка", + "total_amount": 500.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Договор с Лютиком", + "vsp_id": null, + "item_id": "1.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 6, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910004, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-2", + "counterparty": "ООО Лютик", + "total_amount": 70.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 1, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 1 + }, + "header": { + "name": "Техническое обслуживание и эксплуатация оборудования", + "vsp_id": null, + "item_id": "1.01.6.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_4", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 8, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910006, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 1, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 1 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 1, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 2, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "комментарий к плану", + "plan_q1": 250.5, + "plan_q2": 10, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 260.5 + }, + "header": { + "name": "Реконструкция, модернизация: объекты недвижимости", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910002, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 250.5, + "approved_q2": 10, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 260.5 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-2", + "expenses_q1": 1, + "expenses_q2": 2, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": "ООО Ромашка Плюс", + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 99, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 5, + "plan_q2": 6, + "plan_q3": 7, + "plan_q4": 8, + "plan_year": 26 + }, + "header": { + "name": "Капвложения форма 4", + "vsp_id": null, + "item_id": "3.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_4", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 528, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910005, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 5, + "approved_q2": 6, + "approved_q3": 7, + "approved_q4": 8, + "approved_year": 26 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 5 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "только комментарий", + "plan_q1": 0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 0 + }, + "header": { + "name": "ЁЛКА новогодняя", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910003, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 6 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 17, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 17 + }, + "header": { + "name": "Вариант регистра", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "филиал ёлки ", + "direction": "Development", + "num_group": null, + "source_id": 910007, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": "Развитие", + "source_org_unit_id": 910004 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910013, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 17, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 17 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 7 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 11, + "plan_q2": 12, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 23 + }, + "header": { + "name": "Аренда филиал Б", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Филиал Ёлки", + "direction": "Development", + "num_group": null, + "source_id": 910004, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Развитие", + "source_org_unit_id": 910002 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910010, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 11, + "approved_q2": 12, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 23 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 8 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 15, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 15 + }, + "header": { + "name": "Двойник Ёлки", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Филиал Ёлки", + "direction": "Support", + "num_group": null, + "source_id": 910006, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910003 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910012, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 15, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 15 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 9 + } + ], + "count": 9 + } + }, + "GO-admin_one": { + "sha256": "a950918f77374f6338fc21fcfe0066255d1361bdafb3fadf987d3ace384d877c", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 20, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 30, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 100.0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 100.0 + }, + "header": { + "name": "Аренда помещения", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910001, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 100.0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 100.0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-1", + "expenses_q1": 20, + "expenses_q2": 30, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 1234.5, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-1", + "counterparty": "ООО Ромашка", + "total_amount": 500.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Договор с Лютиком", + "vsp_id": null, + "item_id": "1.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 6, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910004, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-2", + "counterparty": "ООО Лютик", + "total_amount": 70.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 1, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 1 + }, + "header": { + "name": "Техническое обслуживание и эксплуатация оборудования", + "vsp_id": null, + "item_id": "1.01.6.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_4", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 8, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910006, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 1, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 1 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 1, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 2, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "комментарий к плану", + "plan_q1": 250.5, + "plan_q2": 10, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 260.5 + }, + "header": { + "name": "Реконструкция, модернизация: объекты недвижимости", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910002, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 250.5, + "approved_q2": 10, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 260.5 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-2", + "expenses_q1": 1, + "expenses_q2": 2, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": "ООО Ромашка Плюс", + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 99, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 5, + "plan_q2": 6, + "plan_q3": 7, + "plan_q4": 8, + "plan_year": 26 + }, + "header": { + "name": "Капвложения форма 4", + "vsp_id": null, + "item_id": "3.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_4", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 528, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910005, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 5, + "approved_q2": 6, + "approved_q3": 7, + "approved_q4": 8, + "approved_year": 26 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 5 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "только комментарий", + "plan_q1": 0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 0 + }, + "header": { + "name": "ЁЛКА новогодняя", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910003, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 6 + } + ], + "count": 6 + } + }, + "GO-admin_page50": { + "sha256": "da542804214418d8d12a007d1a36a536f269730f986cd9592df1774fe9d00f5b", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 20, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 30, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 100.0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 100.0 + }, + "header": { + "name": "Аренда помещения", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910001, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 100.0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 100.0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-1", + "expenses_q1": 20, + "expenses_q2": 30, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 1234.5, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-1", + "counterparty": "ООО Ромашка", + "total_amount": 500.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Договор с Лютиком", + "vsp_id": null, + "item_id": "1.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 6, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910004, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-2", + "counterparty": "ООО Лютик", + "total_amount": 70.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 1, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 1 + }, + "header": { + "name": "Техническое обслуживание и эксплуатация оборудования", + "vsp_id": null, + "item_id": "1.01.6.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_4", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 8, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910006, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 1, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 1 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 1, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 2, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "комментарий к плану", + "plan_q1": 250.5, + "plan_q2": 10, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 260.5 + }, + "header": { + "name": "Реконструкция, модернизация: объекты недвижимости", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910002, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 250.5, + "approved_q2": 10, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 260.5 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-2", + "expenses_q1": 1, + "expenses_q2": 2, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": "ООО Ромашка Плюс", + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 99, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 5, + "plan_q2": 6, + "plan_q3": 7, + "plan_q4": 8, + "plan_year": 26 + }, + "header": { + "name": "Капвложения форма 4", + "vsp_id": null, + "item_id": "3.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_4", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 528, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910005, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 5, + "approved_q2": 6, + "approved_q3": 7, + "approved_q4": 8, + "approved_year": 26 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 5 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "только комментарий", + "plan_q1": 0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 0 + }, + "header": { + "name": "ЁЛКА новогодняя", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910003, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 6 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 17, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 17 + }, + "header": { + "name": "Вариант регистра", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "филиал ёлки ", + "direction": "Development", + "num_group": null, + "source_id": 910007, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": "Развитие", + "source_org_unit_id": 910004 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910013, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 17, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 17 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 7 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 11, + "plan_q2": 12, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 23 + }, + "header": { + "name": "Аренда филиал Б", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Филиал Ёлки", + "direction": "Development", + "num_group": null, + "source_id": 910004, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Развитие", + "source_org_unit_id": 910002 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910010, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 11, + "approved_q2": 12, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 23 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 8 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 15, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 15 + }, + "header": { + "name": "Двойник Ёлки", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Филиал Ёлки", + "direction": "Support", + "num_group": null, + "source_id": 910006, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910003 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910012, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 15, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 15 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 9 + } + ], + "count": 9 + } + }, + "GO-admin_two": { + "sha256": "1590f0ec562eb5484c383c31d341268d005484cdc666aaf539d05d31194a8ac5", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 20, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 30, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 100.0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 100.0 + }, + "header": { + "name": "Аренда помещения", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910001, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 100.0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 100.0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-1", + "expenses_q1": 20, + "expenses_q2": 30, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 1234.5, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-1", + "counterparty": "ООО Ромашка", + "total_amount": 500.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Договор с Лютиком", + "vsp_id": null, + "item_id": "1.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 6, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910004, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-2", + "counterparty": "ООО Лютик", + "total_amount": 70.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + } + ], + "count": 2 + } + }, + "GO-isp_all": { + "sha256": "a950918f77374f6338fc21fcfe0066255d1361bdafb3fadf987d3ace384d877c", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 20, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 30, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 100.0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 100.0 + }, + "header": { + "name": "Аренда помещения", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910001, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 100.0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 100.0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-1", + "expenses_q1": 20, + "expenses_q2": 30, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 1234.5, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-1", + "counterparty": "ООО Ромашка", + "total_amount": 500.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Договор с Лютиком", + "vsp_id": null, + "item_id": "1.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_1", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 6, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910004, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": "ДС-2", + "counterparty": "ООО Лютик", + "total_amount": 70.0, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 1, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 1 + }, + "header": { + "name": "Техническое обслуживание и эксплуатация оборудования", + "vsp_id": null, + "item_id": "1.01.6.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_4", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 8, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910006, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 1, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 1 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 1, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 2, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "комментарий к плану", + "plan_q1": 250.5, + "plan_q2": 10, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 260.5 + }, + "header": { + "name": "Реконструкция, модернизация: объекты недвижимости", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_1", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910002, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 250.5, + "approved_q2": 10, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 260.5 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-2", + "expenses_q1": 1, + "expenses_q2": 2, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": "ООО Ромашка Плюс", + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": 99, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 5, + "plan_q2": 6, + "plan_q3": 7, + "plan_q4": 8, + "plan_year": 26 + }, + "header": { + "name": "Капвложения форма 4", + "vsp_id": null, + "item_id": "3.01.4.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910002, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_4", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 528, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910005, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 5, + "approved_q2": 6, + "approved_q3": 7, + "approved_q4": 8, + "approved_year": 26 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 5 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": "только комментарий", + "plan_q1": 0, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 0 + }, + "header": { + "name": "ЁЛКА новогодняя", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910001, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_1", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910003, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 0, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 0 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 6 + } + ], + "count": 6 + } + }, + "RF-admin_all": { + "sha256": "f6d92bb03d21e74900bcb4cf6f989d8415860c782970046341b71af318a6ad9a", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 300, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 300 + }, + "header": { + "name": "Аренда помещения РФ", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_2", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910007, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 300, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 300 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 777.25, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 5, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": 1000, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Только ККК", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_2", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910008, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-3", + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 42, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 42 + }, + "header": { + "name": "Операционные РФ", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910009, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 42, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 42 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 9, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 9 + }, + "header": { + "name": "РФ филиал Б", + "vsp_id": null, + "item_id": "4.04.1.", + "org_name": "Филиал Ёлки", + "direction": "Support", + "num_group": null, + "source_id": 910005, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 734, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910002 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910011, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 9, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 9 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + } + ], + "count": 4 + } + }, + "RF-admin_one": { + "sha256": "60f7e0b9af69cffea3d3aded1380996e00bdbcd9bb195b51d17ef04282e9465e", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 300, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 300 + }, + "header": { + "name": "Аренда помещения РФ", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_2", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910007, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 300, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 300 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 777.25, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 5, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": 1000, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Только ККК", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_2", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910008, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-3", + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 42, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 42 + }, + "header": { + "name": "Операционные РФ", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910009, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 42, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 42 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + } + ], + "count": 3 + } + }, + "RF-admin_page50": { + "sha256": "f6d92bb03d21e74900bcb4cf6f989d8415860c782970046341b71af318a6ad9a", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 300, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 300 + }, + "header": { + "name": "Аренда помещения РФ", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_2", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910007, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 300, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 300 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 777.25, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 5, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": 1000, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Только ККК", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_2", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910008, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-3", + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 42, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 42 + }, + "header": { + "name": "Операционные РФ", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910009, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 42, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 42 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 9, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 9 + }, + "header": { + "name": "РФ филиал Б", + "vsp_id": null, + "item_id": "4.04.1.", + "org_name": "Филиал Ёлки", + "direction": "Support", + "num_group": null, + "source_id": 910005, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 734, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910002 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910011, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 9, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 9 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 4 + } + ], + "count": 4 + } + }, + "RF-admin_two": { + "sha256": "11139cd93c736a202e54432ed7494588d7bad7b451228b9e59cd0dae60e52963", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 300, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 300 + }, + "header": { + "name": "Аренда помещения РФ", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_2", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910007, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 300, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 300 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 777.25, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 5, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": 1000, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Только ККК", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_2", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910008, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-3", + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + } + ], + "count": 2 + } + }, + "RF-isp_all": { + "sha256": "60f7e0b9af69cffea3d3aded1380996e00bdbcd9bb195b51d17ef04282e9465e", + "body": { + "success": true, + "message": null, + "result": [ + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 300, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 300 + }, + "header": { + "name": "Аренда помещения РФ", + "vsp_id": null, + "item_id": "1.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Support", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "АХР", + "source_form": "FORM_2", + "section_code": "AHR", + "justification": null, + "internal_order": null, + "expense_item_id": 4, + "smeta_direction": "Поддержка", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910007, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 300, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 300 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 1 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 777.25, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": 5, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": 1000, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": null, + "plan_q2": null, + "plan_q3": null, + "plan_q4": null, + "plan_year": null + }, + "header": { + "name": "Только ККК", + "vsp_id": null, + "item_id": "3.01.2.", + "org_name": "Тестовый филиал Альфа", + "direction": "Development", + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "КВ", + "source_form": "FORM_2", + "section_code": "CAP", + "justification": null, + "internal_order": null, + "expense_item_id": 526, + "smeta_direction": "Развитие", + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910008, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": null, + "approved_q2": null, + "approved_q3": null, + "approved_q4": null, + "approved_year": null + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": "Д-3", + "expenses_q1": 777.25, + "expenses_q2": 5, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 2 + }, + { + "row_type": "INPUT", + "depth": 3, + "data": { + "q1": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q2": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q3": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "q4": { + "adj_rf": null, + "adj_ssp": null, + "actual_m1": null, + "actual_m2": null, + "actual_m3": null, + "actual_spod": null, + "adj_comment": null, + "adj_current": null, + "adj_reserve": null, + "payment_act": null, + "payment_date": null, + "target_change": null, + "booking_amount": null, + "payment_amount": null, + "transfer_to_q2": null, + "transfer_to_q3": null, + "transfer_to_q4": null, + "payment_comment": null, + "payment_amount_ho": null, + "payment_amount_rf": null, + "transfer_delay_acts": null, + "transfer_economy_rf": null, + "transfer_to_economy": null, + "base_plan_correction": null, + "transfer_far_comment": null, + "plan_revision_comment": null, + "transfer_next_comment": null, + "plan_revision_increase": null, + "plan_revision_item_adj": null, + "plan_revision_sequester": null, + "plan_revision_eco_change": null, + "transfer_delay_procurement": null, + "base_plan_correction_comment": null + }, + "ckk": { + "comment": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "ceiling_amount": null, + "procurement_plan": null, + "delivery_deadline": null, + "procurement_method": null, + "expenses_next_year_q1": null, + "expenses_next_year_q2": null, + "expenses_next_year_q3": null, + "expenses_next_year_q4": null + }, + "plan": { + "comment": null, + "plan_q1": 42, + "plan_q2": 0, + "plan_q3": 0, + "plan_q4": 0, + "plan_year": 42 + }, + "header": { + "name": "Операционные РФ", + "vsp_id": null, + "item_id": "4.03.0.", + "org_name": "Тестовый филиал Альфа", + "direction": null, + "num_group": null, + "source_id": 910003, + "project_id": null, + "smeta_type": "Операц", + "source_form": "FORM_2", + "section_code": "OPER", + "justification": null, + "internal_order": null, + "expense_item_id": 732, + "smeta_direction": null, + "source_org_unit_id": 910001 + }, + "booking": [ + { + "source": null, + "quarter": null, + "booked_amount_ckk": null, + "booked_amount_contract": null + } + ], + "line_id": 910009, + "reserve": { + "amount_q1": null, + "amount_q2": null, + "amount_q3": null, + "amount_q4": null, + "amount_year": null, + "justification": null + }, + "approved": { + "approved_q1": 42, + "approved_q2": 0, + "approved_q3": 0, + "approved_q4": 0, + "approved_year": 42 + }, + "collegial": { + "note": null, + "approved_amount": null, + "protocol_reference": null + }, + "allocation": { + "contract_ref": null, + "internal_order": null, + "property_object": null, + "allocation_purpose": null + }, + "sequestration": { + "DFIP": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + }, + "SSP_GO": { + "adj_q1": null, + "adj_q2": null, + "adj_q3": null, + "adj_q4": null, + "adj_year": null, + "justification": null + } + }, + "contract_detail": { + "act": null, + "addenda": null, + "comment": null, + "subject": null, + "currency": null, + "deadline": null, + "vat_rate": null, + "reference": null, + "expenses_q1": null, + "expenses_q2": null, + "expenses_q3": null, + "expenses_q4": null, + "rf_schedule": null, + "counterparty": null, + "contract_date": null, + "exchange_rate": null, + "amount_foreign": null, + "ceiling_amount": null, + "payment_scheme": null + }, + "contract_summary": { + "comment": null, + "deadline": null, + "reference": null, + "counterparty": null, + "total_amount": null, + "centralized_flag": null, + "other_ssp_amount": null, + "future_payments_y1": null, + "future_payments_y2": null + } + }, + "sort_order": 3 + } + ], + "count": 3 + } + } +} diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 81cb26d..6879c10 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -4,6 +4,7 @@ import os import pytest from fastapi.testclient import TestClient +from sqlalchemy import text from src.db.base import SessionLocal @@ -30,6 +31,7 @@ def client(): app.dependency_overrides[get_db] = _get_db with TestClient(app) as test_client: try: + test_client.db_session = db_ref yield test_client finally: if db_ref is not None and loop_ref: @@ -79,3 +81,54 @@ def isp_tokens(client, isp_password: str): ) assert response.status_code == 200 return response.json() + + +SVOD2_YEAR = 2031 +SVOD2_SEED_PATH = os.path.join(os.path.dirname(__file__), "..", "sqls", "svod2_seed.sql") + + +def legacy_full(client, sheet: str, headers: dict, org_ids=None) -> list[dict]: + """Прежний ответ свода 2 без параметров нового режима (оракульная база).""" + url = f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}" + if org_ids is not None: + url += f"?org_ids={org_ids}" + response = client.get(url, headers=headers) + assert response.status_code == 200, response.text + return response.json()["result"] + + +@pytest.fixture +def svod2_sql(client): + """Выполняет text(sql) на сессии клиента (тот же цикл событий, что у запросов).""" + + def _run(sql: str, params: dict | None = None): + async def _exec(): + result = await client.db_session.execute(text(sql), params or {}) + return list(result.fetchall()) if result.returns_rows else [] + + return client.portal.call(_exec) + + return _run + + +@pytest.fixture +def svod2_data(client, svod2_sql) -> dict: + """Засевает данные свода 2 (откатываются вместе с сессией клиента).""" + with open(SVOD2_SEED_PATH, "r", encoding="utf-8") as f: + script = f.read() + for statement in script.split(";"): + statement = statement.strip() + if statement: + svod2_sql(statement) + # id строки contract_detail: contract_detail.line_id = 910002, технический id, + # который попадает в шапку строки, — expense_item_id этой строки + cd_id = svod2_sql("SELECT expense_item_id FROM v3.budget_line WHERE id = 910002")[0][0] + return { + "year": SVOD2_YEAR, + "org_a": 910001, + "org_b": 910002, + "org_b_twin": 910003, + "org_b_case": 910004, + "cd_id": cd_id, + "ckk_only_value": "777.25", + } diff --git a/api/tests/integration/test_svod2_legacy_golden.py b/api/tests/integration/test_svod2_legacy_golden.py new file mode 100644 index 0000000..4f647ca --- /dev/null +++ b/api/tests/integration/test_svod2_legacy_golden.py @@ -0,0 +1,70 @@ +"""Эталон прежнего ответа свода 2 (байт-в-байт). + +Эталон записан один раз на коде «до» (SVOD2_WRITE_GOLDEN=1) и больше не перезаписывается. +""" +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path + +import pytest + +GOLDEN_PATH = Path(__file__).resolve().parent.parent / "fixtures" / "svod2_legacy_golden.json" + + +@dataclass(frozen=True) +class GoldenCase: + name: str + query: str = "" + who: str = "admin" + + def url(self, sheet: str, data: dict) -> str: + query = self.query.format(org_a=data["org_a"]) + base = f"/api/v1/svod/2/{sheet}/{data['year']}" + return f"{base}?{query}" if query else base + + def headers(self, auth_headers, admin_tokens, isp_tokens) -> dict: + return auth_headers(isp_tokens if self.who == "isp" else admin_tokens) + + +GOLDEN_CASES = [ + GoldenCase("admin_all"), + GoldenCase("admin_one", "org_ids={org_a}"), + GoldenCase("isp_all", who="isp"), + GoldenCase("admin_page50", "offset=0&limit=50"), + GoldenCase("admin_two", "offset=0&limit=2"), +] + + +def _golden() -> dict: + with open(GOLDEN_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def _record(key: str, content: bytes) -> None: + data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) if GOLDEN_PATH.exists() else {} + data[key] = {"sha256": hashlib.sha256(content).hexdigest(), "body": json.loads(content)} + GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True) + GOLDEN_PATH.write_text( + json.dumps(dict(sorted(data.items())), ensure_ascii=False, indent=1) + "\n", + encoding="utf-8", + ) + + +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.name) +def test_legacy_response_is_byte_identical( + client, svod2_data, admin_tokens, isp_tokens, auth_headers, sheet, case +): + r = client.get( + case.url(sheet, svod2_data), + headers=case.headers(auth_headers, admin_tokens, isp_tokens), + ) + assert r.status_code == 200 + if case.name == "admin_all": + assert r.json()["result"], "засев не дал строк свода 2" + key = f"{sheet}-{case.name}" + if os.getenv("SVOD2_WRITE_GOLDEN") == "1": + _record(key, r.content) + assert hashlib.sha256(r.content).hexdigest() == _golden()[key]["sha256"] diff --git a/api/tests/integration/test_svod2_paging_api.py b/api/tests/integration/test_svod2_paging_api.py new file mode 100644 index 0000000..795c1b5 --- /dev/null +++ b/api/tests/integration/test_svod2_paging_api.py @@ -0,0 +1,260 @@ +"""Тесты-оракулы нового режима API свода 2 (постраничная выдача, поиск, фильтры, варианты, compact). + +Оракул — функции экрана «до» из tests.svod2_oracle, применённые к прежнему полному ответу. +""" +import pytest + +from tests.integration.conftest import SVOD2_YEAR, legacy_full +from tests.svod2_oracle import ( + compact_projection, + screen_facets, + screen_found, + screen_rows, +) + +SMETA_TYPE = "КВ" +SMETA_TYPE_VARIANT = SMETA_TYPE.lower() + " " + +SEARCHES = [ + "аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда ", + " ", "1", ",", "777.25", "", "я" * 5000, +] + +FILTERS = { + "type-case-space": {"smeta_type": SMETA_TYPE_VARIANT}, + "org-twin": {"org_name": "Филиал Ёлки"}, + "org-case-variant": {"org_name": "филиал ёлки "}, + "unknown": {"smeta_type": "нет такого"}, + "dir+org": {"smeta_direction": "Развитие", "org_name": "Филиал Ёлки"}, + "search+type": {"search": "аренда", "smeta_type": SMETA_TYPE_VARIANT}, + "search+type-hit": {"search": "о", "smeta_type": SMETA_TYPE_VARIANT}, + "long": {"org_name": "я" * 5000}, +} + +SHEETS = ["GO", "RF"] + + +def _url(sheet: str, suffix: str = "") -> str: + return f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}{suffix}" + + +def _line_ids(rows: list[dict]) -> list[int]: + return [r["data"]["line_id"] for r in rows] + + +def _resolve_search(search: str, data: dict) -> str: + return str(data["cd_id"]) if search == "" else search + + +def _split(params: dict) -> tuple[str, dict]: + filters = dict(params) + search = filters.pop("search", "") + return search, filters + + +def _all_pages(client, sheet, headers, limit, **params) -> tuple[list[dict], list[int]]: + """Все страницы нового режима: (строки, total каждой страницы).""" + rows: list[dict] = [] + totals: list[int] = [] + offset = 0 + while True: + r = client.get( + _url(sheet), + params={**params, "offset": offset, "limit": limit}, + headers=headers, + ) + assert r.status_code == 200, r.text + body = r.json() + totals.append(body["total"]) + rows.extend(body["result"]) + if len(body["result"]) < limit: + return rows, totals + offset += limit + + +@pytest.fixture +def headers(admin_tokens, auth_headers): + return auth_headers(admin_tokens) + + +def test_legacy_mode_has_no_total(client, svod2_data, headers): + r = client.get(_url("GO", "?offset=0&limit=2"), headers=headers) + assert r.status_code == 200 + body = r.json() + assert body["count"] <= 2 + assert len(body["result"]) <= 2 + assert "total" not in body + + +@pytest.mark.parametrize( + "q", ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad", "view=bad"] +) +def test_invalid_params_422(client, svod2_data, headers, q): + r = client.get(_url("GO", f"?{q}"), headers=headers) + assert r.status_code == 422 + + +@pytest.mark.parametrize("sheet", SHEETS) +def test_pages_stitch_to_full_list(client, svod2_data, headers, sheet): + full = legacy_full(client, sheet, headers) + assert full + rows, totals = _all_pages(client, sheet, headers, 2, view="full") + assert _line_ids(rows) == _line_ids(full) + assert totals and all(t == len(full) for t in totals) + + +@pytest.mark.parametrize("sheet", SHEETS) +def test_page_past_end_has_total(client, svod2_data, headers, sheet): + full = legacy_full(client, sheet, headers) + r = client.get( + _url(sheet), + params={"view": "full", "offset": len(full), "limit": 2}, + headers=headers, + ) + assert r.status_code == 200 + body = r.json() + assert body["result"] == [] + assert body["count"] == 0 + assert body["total"] == len(full) + + +def test_view_full_rows_equal_legacy_rows(client, svod2_data, headers): + full = legacy_full(client, "RF", headers) + r = client.get( + _url("RF"), params={"view": "full", "offset": 0, "limit": 50}, headers=headers + ) + assert r.status_code == 200 + body = r.json() + assert "total" in body + assert body["result"] == full[:50] + + +@pytest.mark.parametrize("sheet", SHEETS) +@pytest.mark.parametrize("search", SEARCHES, ids=[str(i) for i in range(len(SEARCHES))]) +def test_search_matches_screen_oracle(client, svod2_data, headers, sheet, search): + search = _resolve_search(search, svod2_data) + full = legacy_full(client, sheet, headers) + expected = screen_rows(full, search) + rows, totals = _all_pages(client, sheet, headers, 3, view="compact", search=search) + assert _line_ids(rows) == _line_ids(expected) + assert all(t == len(expected) for t in totals) + + +@pytest.mark.parametrize("sheet", SHEETS) +@pytest.mark.parametrize("name", list(FILTERS)) +def test_filters_match_screen_oracle(client, svod2_data, headers, sheet, name): + params = FILTERS[name] + search, filters = _split(params) + full = legacy_full(client, sheet, headers) + expected = screen_rows(full, search, filters) + rows, totals = _all_pages(client, sheet, headers, 3, view="compact", **params) + assert _line_ids(rows) == _line_ids(expected) + assert all(t == len(expected) for t in totals) + + +def _facets_to_dict(result: dict) -> dict: + out = {"total": result["total"], "search_total": result["search_total"]} + for col in ("smeta_type", "smeta_direction", "org_name"): + out[col] = {item["value"]: item["count"] for item in result[col]} + return out + + +@pytest.mark.parametrize("sheet", SHEETS) +@pytest.mark.parametrize("name", list(FILTERS)) +@pytest.mark.parametrize("search", SEARCHES[:6], ids=[str(i) for i in range(6)]) +def test_facets_match_screen_oracle(client, svod2_data, headers, sheet, name, search): + _, filters = _split(FILTERS[name]) + full = legacy_full(client, sheet, headers) + expected = screen_facets(full, search, filters) + r = client.get( + _url(sheet, "/facets"), params={"search": search, **filters}, headers=headers + ) + assert r.status_code == 200, r.text + assert _facets_to_dict(r.json()["result"]) == expected + + +@pytest.mark.parametrize("sheet", SHEETS) +@pytest.mark.parametrize("search", SEARCHES, ids=[str(i) for i in range(len(SEARCHES))]) +def test_found_matches_screen_oracle(client, svod2_data, headers, sheet, search): + search = _resolve_search(search, svod2_data) + full = legacy_full(client, sheet, headers) + r = client.get(_url(sheet, "/facets"), params={"search": search}, headers=headers) + assert r.status_code == 200, r.text + result = r.json()["result"] + assert (result["search_total"], result["total"]) == screen_found(full, search) + + +@pytest.mark.parametrize("sheet", SHEETS) +def test_compact_is_projection_of_full(client, svod2_data, headers, sheet): + params = {"offset": 0, "limit": 5} + full = client.get(_url(sheet), params={**params, "view": "full"}, headers=headers) + compact = client.get(_url(sheet), params={**params, "view": "compact"}, headers=headers) + assert full.status_code == 200 and compact.status_code == 200 + full_rows = full.json()["result"] + compact_rows = compact.json()["result"] + assert full_rows and len(full_rows) == len(compact_rows) + for f, c in zip(full_rows, compact_rows): + assert (c["row_type"], c["depth"], c["sort_order"]) == ( + f["row_type"], f["depth"], f["sort_order"], + ) + assert c["data"] == compact_projection(f["data"]) + + +def test_rf_ckk_only_value_is_searchable(client, svod2_data, headers): + r = client.get( + _url("RF"), + params={"view": "compact", "search": svod2_data["ckk_only_value"]}, + headers=headers, + ) + assert r.status_code == 200 + body = r.json() + assert body["total"] >= 1 + assert body["result"], "строка с значением только из ККК не найдена поиском" + + +@pytest.mark.parametrize("sheet", SHEETS) +def test_isp_foreign_org_is_empty(client, svod2_data, isp_tokens, auth_headers, sheet): + isp = auth_headers(isp_tokens) + r = client.get( + _url(sheet), + params={"view": "full", "org_ids": svod2_data["org_b"], "offset": 0, "limit": 50}, + headers=isp, + ) + assert r.status_code == 200 + body = r.json() + assert body["result"] == [] + assert body["total"] == 0 + + f = client.get( + _url(sheet, "/facets"), params={"org_ids": svod2_data["org_b"]}, headers=isp + ) + assert f.status_code == 200 + result = f.json()["result"] + assert result["total"] == 0 and result["search_total"] == 0 + assert result["smeta_type"] == [] + assert result["smeta_direction"] == [] + assert result["org_name"] == [] + + +@pytest.mark.parametrize("sheet", SHEETS) +def test_isp_sees_only_own_org( + client, svod2_data, headers, isp_tokens, auth_headers, sheet +): + admin_rows = legacy_full(client, sheet, headers) + own = [ + r for r in admin_rows + if r["data"]["header"]["source_org_unit_id"] == svod2_data["org_a"] + ] + assert own + r = client.get( + _url(sheet), + params={"view": "full", "offset": 0, "limit": 50}, + headers=auth_headers(isp_tokens), + ) + assert r.status_code == 200 + assert r.json()["total"] == len(own) + + +def test_facets_unknown_sheet_404(client, svod2_data, headers): + r = client.get(_url("XX", "/facets"), headers=headers) + assert r.status_code == 404 diff --git a/api/tests/sqls/svod2_seed.sql b/api/tests/sqls/svod2_seed.sql new file mode 100644 index 0000000..f5d84a7 --- /dev/null +++ b/api/tests/sqls/svod2_seed.sql @@ -0,0 +1,57 @@ +-- Засев свода 2 (год 2031). Скрипт режется по точке с запятой, поэтому без двоеточий и фигурных скобок в тексте. +INSERT INTO v3.org_unit (id, title, is_active, is_ssp) VALUES + (910001, 'Тестовый филиал Альфа', true, false), + (910002, 'Филиал Ёлки', true, false), + (910003, 'Филиал Ёлки', true, false), + (910004, 'филиал ёлки ', true, false); + +INSERT INTO v3.user_org (id, user_id, org_unit_id) VALUES (910001, 2, 910001); + +INSERT INTO v3.budget_form (id, form_type_code, "year", org_unit_id) VALUES + (910001, 'FORM_1', 2031, 910001), + (910002, 'FORM_4', 2031, 910001), + (910003, 'FORM_2', 2031, 910001), + (910004, 'FORM_1', 2031, 910002), + (910005, 'FORM_2', 2031, 910002), + (910006, 'FORM_1', 2031, 910003), + (910007, 'FORM_1', 2031, 910004); + +INSERT INTO v3.budget_line (id, budget_form_id, expense_item_id, "name", direction) VALUES + (910001, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда помещения', 'Support'), + (910002, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), NULL, 'Development'), + (910003, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'ЁЛКА новогодняя', NULL), + (910004, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 5 LIMIT 1), 'Договор с Лютиком', 'Support'), + (910005, 910002, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 5 LIMIT 1), 'Капвложения форма 4', 'Development'), + (910006, 910002, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 7 LIMIT 1), NULL, NULL), + (910007, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда помещения РФ', 'Support'), + (910008, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), 'Только ККК', 'Development'), + (910009, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'Операционные РФ', NULL), + (910010, 910004, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда филиал Б', 'Development'), + (910011, 910005, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 5 LIMIT 1), 'РФ филиал Б', 'Support'), + (910012, 910006, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), 'Двойник Ёлки', 'Support'), + (910013, 910007, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'Вариант регистра', 'Development'); + +INSERT INTO v3.plan (line_id, plan_q1, plan_q2, plan_q3, plan_q4, "comment") VALUES + (910001, 100.00, 0, 0, 0, NULL), + (910002, 250.50, 10, 0, 0, 'комментарий к плану'), + (910003, 0, 0, 0, 0, 'только комментарий'), + (910005, 5, 6, 7, 8, NULL), + (910006, 1, 0, 0, 0, NULL), + (910007, 300, 0, 0, 0, NULL), + (910009, 42, 0, 0, 0, NULL), + (910010, 11, 12, 0, 0, NULL), + (910011, 9, 0, 0, 0, NULL), + (910012, 15, 0, 0, 0, NULL), + (910013, 17, 0, 0, 0, NULL); + +INSERT INTO v3.contract_summary (line_id, total_amount, counterparty, reference) VALUES + (910001, 500.00, 'ООО Ромашка', 'ДС-1'), + (910004, 70.00, 'ООО Лютик', 'ДС-2'); + +INSERT INTO v3.contract_detail (line_id, counterparty, reference, ceiling_amount, expenses_q1, expenses_q2) VALUES + (910001, NULL, 'Д-1', 1234.50, 20, 30), + (910002, 'ООО Ромашка Плюс', 'Д-2', 99, 1, 2), + (910008, NULL, 'Д-3', NULL, NULL, NULL); + +INSERT INTO v3.ckk (line_id, ceiling_amount, expenses_q1, expenses_q2) VALUES + (910008, 1000, 777.25, 5); -- 2.47.2 From a62add97790173cd861f09fd7f9340fecb3d70fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:24:34 +0400 Subject: [PATCH 09/17] =?UTF-8?q?perf(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=200053:=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D1=8B=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20JS=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=B0=D0=BA=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/0053_svod2_server_paging.py | 15 ++ .../versions/sql/0053_svod2_server_paging.sql | 237 ++++++++++++++++++ api/tests/integration/test_svod2_sql.py | 53 ++++ 3 files changed, 305 insertions(+) create mode 100644 api/alembic/versions/0053_svod2_server_paging.py create mode 100644 api/alembic/versions/sql/0053_svod2_server_paging.sql create mode 100644 api/tests/integration/test_svod2_sql.py diff --git a/api/alembic/versions/0053_svod2_server_paging.py b/api/alembic/versions/0053_svod2_server_paging.py new file mode 100644 index 0000000..4346091 --- /dev/null +++ b/api/alembic/versions/0053_svod2_server_paging.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0053" +down_revision = "0049" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0053_svod2_server_paging.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0053_svod2_server_paging.sql b/api/alembic/versions/sql/0053_svod2_server_paging.sql new file mode 100644 index 0000000..40c3cac --- /dev/null +++ b/api/alembic/versions/sql/0053_svod2_server_paging.sql @@ -0,0 +1,237 @@ +-- AURORA-1843, миграция 0053, часть 1: нормализаторы поиска как в JS и компактная проекция строки свода 2. + +-- Регистр без зависимости от локали: явная таблица translate (ASCII, кириллица, Ґ, латиница-1 без U+00D7). +CREATE OR REPLACE FUNCTION v3.svod2_casefold(x text) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT translate( + x, + E'\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u0419\u041A\u041B\u041C\u041D\u041E\u041F\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042A\u042B\u042C\u042D\u042E\u042F\u0400\u0401\u0402\u0403\u0404\u0405\u0406\u0407\u0408\u0409\u040A\u040B\u040C\u040D\u040E\u040F\u0490\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE', + E'\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u0439\u043A\u043B\u043C\u043D\u043E\u043F\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044A\u044B\u044C\u044D\u044E\u044F\u0450\u0451\u0452\u0453\u0454\u0455\u0456\u0457\u0458\u0459\u045A\u045B\u045C\u045D\u045E\u045F\u0491\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE' + ) +$function$; + +-- Обрезка как String.prototype.trim: ECMAScript WhiteSpace и LineTerminator. +CREATE OR REPLACE FUNCTION v3.svod2_js_trim(x text) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT btrim(x, E'\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF') +$function$; + +-- Запись числа как JS Number::toString. Цифры берутся из float8::text при extra_float_digits = 1 +-- (кратчайшая точная запись), SET в функции изолирует результат от настроек сессии. +CREATE OR REPLACE FUNCTION v3.svod2_js_number_text(x double precision) +RETURNS text +LANGUAGE plpgsql IMMUTABLE +SET extra_float_digits = 1 +AS $function$ +DECLARE + t text; + neg boolean := false; + mant text; + ex integer := 0; + int_part text; + digits text; + lead integer; + n integer; + k integer; + res text; +BEGIN + IF x IS NULL THEN + RETURN NULL; + END IF; + IF x = 0 THEN + RETURN '0'; + END IF; + t := x::text; + IF t IN ('NaN', 'Infinity', '-Infinity') THEN + RETURN t; + END IF; + IF left(t, 1) = '-' THEN + neg := true; + t := substr(t, 2); + END IF; + IF position('e' IN t) > 0 THEN + mant := split_part(t, 'e', 1); + ex := split_part(t, 'e', 2)::integer; + ELSE + mant := t; + END IF; + IF position('.' IN mant) > 0 THEN + int_part := split_part(mant, '.', 1); + ELSE + int_part := mant; + END IF; + digits := replace(mant, '.', ''); + lead := length(digits) - length(ltrim(digits, '0')); + digits := ltrim(digits, '0'); + digits := rtrim(digits, '0'); + n := length(int_part) + ex - lead; + k := length(digits); + IF n >= k AND n <= 21 THEN + res := digits || repeat('0', n - k); + ELSIF n > 0 AND n <= 21 THEN + res := substr(digits, 1, n) || '.' || substr(digits, n + 1); + ELSIF n > -6 AND n <= 0 THEN + res := '0.' || repeat('0', -n) || digits; + ELSE + res := substr(digits, 1, 1); + IF k > 1 THEN + res := res || '.' || substr(digits, 2); + END IF; + res := res || 'e' || CASE WHEN n - 1 >= 0 THEN '+' ELSE '-' END || abs(n - 1)::text; + END IF; + IF neg THEN + res := '-' || res; + END IF; + RETURN res; +END +$function$; + +-- Компактная проекция data строки свода 2: jsonb_strip_nulls по перечисленным путям. +CREATE OR REPLACE FUNCTION v3.svod2_compact(d jsonb) +RETURNS jsonb +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT jsonb_strip_nulls(jsonb_build_object( + 'line_id', d #> '{line_id}', + 'header', jsonb_build_object( + 'source_form', d #> '{header,source_form}', + 'source_id', d #> '{header,source_id}', + 'smeta_type', d #> '{header,smeta_type}', + 'smeta_direction', d #> '{header,smeta_direction}', + 'org_name', d #> '{header,org_name}', + 'name', d #> '{header,name}', + 'item_id', d #> '{header,item_id}', + 'num_group', d #> '{header,num_group}', + 'justification', d #> '{header,justification}' + ), + 'plan', jsonb_build_object( + 'plan_q1', d #> '{plan,plan_q1}', + 'plan_q2', d #> '{plan,plan_q2}', + 'plan_q3', d #> '{plan,plan_q3}', + 'plan_q4', d #> '{plan,plan_q4}' + ), + 'allocation', jsonb_build_object( + 'internal_order', d #> '{allocation,internal_order}', + 'property_object', d #> '{allocation,property_object}' + ), + 'sequestration', jsonb_build_object( + 'DFIP', jsonb_build_object( + 'adj_q1', d #> '{sequestration,DFIP,adj_q1}', + 'adj_q2', d #> '{sequestration,DFIP,adj_q2}', + 'adj_q3', d #> '{sequestration,DFIP,adj_q3}', + 'adj_q4', d #> '{sequestration,DFIP,adj_q4}' + ) + ), + 'reserve', jsonb_build_object( + 'amount_q1', d #> '{reserve,amount_q1}', + 'amount_q2', d #> '{reserve,amount_q2}', + 'amount_q3', d #> '{reserve,amount_q3}', + 'amount_q4', d #> '{reserve,amount_q4}' + ), + 'approved', jsonb_build_object( + 'approved_q1', d #> '{approved,approved_q1}', + 'approved_q2', d #> '{approved,approved_q2}', + 'approved_q3', d #> '{approved,approved_q3}', + 'approved_q4', d #> '{approved,approved_q4}' + ), + 'contract_detail', jsonb_build_object( + 'counterparty', d #> '{contract_detail,counterparty}', + 'reference', d #> '{contract_detail,reference}', + 'subject', d #> '{contract_detail,subject}', + 'currency', d #> '{contract_detail,currency}', + 'ceiling_amount', d #> '{contract_detail,ceiling_amount}', + 'expenses_q1', d #> '{contract_detail,expenses_q1}', + 'expenses_q2', d #> '{contract_detail,expenses_q2}', + 'expenses_q3', d #> '{contract_detail,expenses_q3}', + 'expenses_q4', d #> '{contract_detail,expenses_q4}', + 'vat_rate', d #> '{contract_detail,vat_rate}', + 'deadline', d #> '{contract_detail,deadline}' + ), + 'q1', jsonb_build_object( + 'adj_current', d #> '{q1,adj_current}', + 'adj_ssp', d #> '{q1,adj_ssp}', + 'adj_rf', d #> '{q1,adj_rf}', + 'adj_reserve', d #> '{q1,adj_reserve}', + 'payment_date', d #> '{q1,payment_date}', + 'payment_amount', d #> '{q1,payment_amount}', + 'payment_amount_ho', d #> '{q1,payment_amount_ho}', + 'payment_amount_rf', d #> '{q1,payment_amount_rf}', + 'payment_act', d #> '{q1,payment_act}', + 'booking_amount', d #> '{q1,booking_amount}', + 'actual_m1', d #> '{q1,actual_m1}', + 'actual_m2', d #> '{q1,actual_m2}', + 'actual_m3', d #> '{q1,actual_m3}', + 'transfer_to_q2', d #> '{q1,transfer_to_q2}', + 'transfer_to_q3', d #> '{q1,transfer_to_q3}', + 'transfer_to_q4', d #> '{q1,transfer_to_q4}', + 'transfer_to_economy', d #> '{q1,transfer_to_economy}' + ), + 'q2', jsonb_build_object( + 'adj_current', d #> '{q2,adj_current}', + 'adj_ssp', d #> '{q2,adj_ssp}', + 'adj_rf', d #> '{q2,adj_rf}', + 'adj_reserve', d #> '{q2,adj_reserve}', + 'payment_date', d #> '{q2,payment_date}', + 'payment_amount', d #> '{q2,payment_amount}', + 'payment_amount_ho', d #> '{q2,payment_amount_ho}', + 'payment_amount_rf', d #> '{q2,payment_amount_rf}', + 'payment_act', d #> '{q2,payment_act}', + 'booking_amount', d #> '{q2,booking_amount}', + 'actual_m1', d #> '{q2,actual_m1}', + 'actual_m2', d #> '{q2,actual_m2}', + 'actual_m3', d #> '{q2,actual_m3}', + 'transfer_to_q3', d #> '{q2,transfer_to_q3}', + 'transfer_to_q4', d #> '{q2,transfer_to_q4}', + 'transfer_to_economy', d #> '{q2,transfer_to_economy}', + 'plan_revision_eco_change', d #> '{q2,plan_revision_eco_change}', + 'plan_revision_item_adj', d #> '{q2,plan_revision_item_adj}', + 'plan_revision_increase', d #> '{q2,plan_revision_increase}', + 'plan_revision_sequester', d #> '{q2,plan_revision_sequester}' + ), + 'q3', jsonb_build_object( + 'adj_current', d #> '{q3,adj_current}', + 'adj_ssp', d #> '{q3,adj_ssp}', + 'adj_rf', d #> '{q3,adj_rf}', + 'adj_reserve', d #> '{q3,adj_reserve}', + 'payment_date', d #> '{q3,payment_date}', + 'payment_amount', d #> '{q3,payment_amount}', + 'payment_amount_ho', d #> '{q3,payment_amount_ho}', + 'payment_amount_rf', d #> '{q3,payment_amount_rf}', + 'payment_act', d #> '{q3,payment_act}', + 'booking_amount', d #> '{q3,booking_amount}', + 'actual_m1', d #> '{q3,actual_m1}', + 'actual_m2', d #> '{q3,actual_m2}', + 'actual_m3', d #> '{q3,actual_m3}', + 'transfer_to_q4', d #> '{q3,transfer_to_q4}', + 'transfer_to_economy', d #> '{q3,transfer_to_economy}', + 'plan_revision_eco_change', d #> '{q3,plan_revision_eco_change}', + 'plan_revision_item_adj', d #> '{q3,plan_revision_item_adj}', + 'plan_revision_increase', d #> '{q3,plan_revision_increase}', + 'plan_revision_sequester', d #> '{q3,plan_revision_sequester}' + ), + 'q4', jsonb_build_object( + 'adj_current', d #> '{q4,adj_current}', + 'adj_ssp', d #> '{q4,adj_ssp}', + 'adj_rf', d #> '{q4,adj_rf}', + 'adj_reserve', d #> '{q4,adj_reserve}', + 'payment_date', d #> '{q4,payment_date}', + 'payment_amount', d #> '{q4,payment_amount}', + 'payment_amount_ho', d #> '{q4,payment_amount_ho}', + 'payment_amount_rf', d #> '{q4,payment_amount_rf}', + 'payment_act', d #> '{q4,payment_act}', + 'booking_amount', d #> '{q4,booking_amount}', + 'actual_m1', d #> '{q4,actual_m1}', + 'actual_m2', d #> '{q4,actual_m2}', + 'actual_m3', d #> '{q4,actual_m3}', + 'actual_spod', d #> '{q4,actual_spod}', + 'plan_revision_eco_change', d #> '{q4,plan_revision_eco_change}', + 'plan_revision_item_adj', d #> '{q4,plan_revision_item_adj}', + 'plan_revision_increase', d #> '{q4,plan_revision_increase}', + 'plan_revision_sequester', d #> '{q4,plan_revision_sequester}' + ) + )) +$function$; diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py new file mode 100644 index 0000000..4944dc4 --- /dev/null +++ b/api/tests/integration/test_svod2_sql.py @@ -0,0 +1,53 @@ +"""SQL-нормализаторы свода 2 (миграция 0053): регистр, обрезка, запись числа как в JS, компактная проекция.""" +import json +from pathlib import Path + +import pytest + +from tests.integration.conftest import legacy_full +from tests.svod2_oracle import compact_projection + +NUM = json.loads( + (Path(__file__).resolve().parent.parent / "fixtures" / "js_number_text_vectors.json").read_text( + encoding="utf-8" + ) +) + + +def test_js_number_text_matches_node_vectors(svod2_sql): + for v in NUM: + got = svod2_sql( + "SELECT v3.svod2_js_number_text((CAST(:x AS jsonb) #>> '{}')::float8) AS t", + {"x": v["input"]}, + )[0][0] + assert got == v["js"], v + + +def test_js_number_text_ignores_session_extra_float_digits(svod2_sql): + svod2_sql("SET LOCAL extra_float_digits = 0", None) + got = svod2_sql("SELECT v3.svod2_js_number_text(0.1::float8 + 0.2::float8) AS t", None)[0][0] + assert got == "0.30000000000000004" + + +@pytest.mark.parametrize( + "src", + ["АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", "ЄІЇЎЂЏҐ", "ABCxyz", "ÀÉÖÞ", "×", "аРЕНДА 1"], +) +def test_casefold_matches_js_lower(svod2_sql, src): + assert svod2_sql("SELECT v3.svod2_casefold(:s) AS t", {"s": src})[0][0] == src.lower() + + +def test_casefold_independent_of_collation(svod2_sql): + got = svod2_sql("""SELECT v3.svod2_casefold('ЁЛКА' COLLATE "C") AS t""", None)[0][0] + assert got == "ёлка" + + +def test_js_trim_ecmascript_whitespace(svod2_sql): + assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "   x \t"})[0][0] == "x" + assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "​ x"})[0][0] == "​ x" + + +def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_tokens, auth_headers): + full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"] + got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0][0] + assert got == compact_projection(full) -- 2.47.2 From 719f5688630f69dd41fe59e7a0fc7ecab01142f2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:40:41 +0400 Subject: [PATCH 10/17] =?UTF-8?q?perf(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20svod2=5Flines,=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B=20=D1=81=20total=5Fcount,=20=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20=D0=B2=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0053_svod2_server_paging.sql | 672 ++++++++++++++++++ api/tests/integration/test_svod2_sql.py | 67 ++ .../unit/test_summary2_related_records_sql.py | 14 +- 3 files changed, 750 insertions(+), 3 deletions(-) diff --git a/api/alembic/versions/sql/0053_svod2_server_paging.sql b/api/alembic/versions/sql/0053_svod2_server_paging.sql index 40c3cac..e30e602 100644 --- a/api/alembic/versions/sql/0053_svod2_server_paging.sql +++ b/api/alembic/versions/sql/0053_svod2_server_paging.sql @@ -235,3 +235,675 @@ AS $function$ ) )) $function$; + +-- Прежние 6-аргументные сигнатуры заменяются: тип результата меняется, перегрузка не нужна. +DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar); +DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar); + +-- Общий отбор строк свода 2 для страниц и вариантов фильтров. Условия отбора как в 0049, +-- форма выбирается по p_sheet; p_search (уже нормализованный) отсекает строки без совпадения +-- по полям header, contract_summary и contract_detail, как matchesSearch экрана. +CREATE OR REPLACE FUNCTION v3.svod2_lines( + p_sheet varchar, + p_year integer, + p_org_unit_ids integer[], + p_search text +) +RETURNS TABLE( + line_id integer, form_type_code varchar, form_id integer, org_unit_id integer, + org_name varchar, section_code varchar, item_id varchar, + smeta_type varchar, smeta_direction varchar +) +LANGUAGE sql STABLE +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail +), eligible_lines AS ( + -- svod2_lines eligible: begin + SELECT DISTINCT + bl.id AS line_id, + bf.form_type_code, + bf.id AS form_id, + bf.org_unit_id, + ou.title AS org_name, + ei.section_code, + ei.item_id, + CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END::varchar AS smeta_direction, + CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВ' + WHEN 'OPER' THEN 'Операц' END::varchar AS smeta_type + FROM v3.budget_form bf + JOIN v3.budget_line bl ON bl.budget_form_id = bf.id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + LEFT JOIN v3.plan presence_plan ON presence_plan.line_id = bl.id + LEFT JOIN v3.reserve presence_reserve ON presence_reserve.line_id = bl.id + LEFT JOIN v3.contract_summary presence_contract_summary ON presence_contract_summary.line_id = bl.id + LEFT JOIN v3.allocation presence_allocation ON presence_allocation.line_id = bl.id + LEFT JOIN v3.collegial_approval presence_collegial_approval ON presence_collegial_approval.line_id = bl.id + LEFT JOIN v3.ckk presence_ckk ON presence_ckk.line_id = bl.id + LEFT JOIN v3.contract_detail presence_contract_detail ON presence_contract_detail.line_id = bl.id + LEFT JOIN v3.sequestration presence_sequestration ON presence_sequestration.line_id = bl.id + LEFT JOIN v3.booking presence_booking ON presence_booking.line_id = bl.id + LEFT JOIN v3.budget_line_quarter presence_budget_line_quarter ON presence_budget_line_quarter.line_id = bl.id + LEFT JOIN v3.rent_detail presence_rent_detail ON presence_rent_detail.line_id = bl.id + LEFT JOIN v3.security_detail presence_security_detail ON presence_security_detail.line_id = bl.id + LEFT JOIN v3.utility_detail presence_utility_detail ON presence_utility_detail.line_id = bl.id + WHERE bf.year = p_year + AND ( (p_sheet = 'GO' AND bf.form_type_code IN ('FORM_1', 'FORM_4')) + OR (p_sheet = 'RF' AND bf.form_type_code = 'FORM_2')) + AND ei.sheet IN ('AHR', 'CAP', 'OPER') + AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 + OR bf.org_unit_id = ANY(p_org_unit_ids)) + AND ( + ( + presence_plan.plan_q1 <> 0 + OR presence_plan.plan_q2 <> 0 + OR presence_plan.plan_q3 <> 0 + OR presence_plan.plan_q4 <> 0 + OR presence_plan.comment ~ '[^[:space:]]' + ) + OR presence_reserve.line_id IS NOT NULL + OR presence_contract_summary.line_id IS NOT NULL + OR presence_allocation.line_id IS NOT NULL + OR presence_collegial_approval.line_id IS NOT NULL + OR presence_ckk.line_id IS NOT NULL + OR presence_contract_detail.line_id IS NOT NULL + OR presence_sequestration.line_id IS NOT NULL + OR presence_booking.line_id IS NOT NULL + OR ( + presence_budget_line_quarter.adj_current <> 0 + OR presence_budget_line_quarter.adj_ssp <> 0 + OR presence_budget_line_quarter.adj_rf <> 0 + OR presence_budget_line_quarter.adj_reserve <> 0 + OR presence_budget_line_quarter.adj_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.target_change <> 0 + OR presence_budget_line_quarter.base_plan_correction <> 0 + OR presence_budget_line_quarter.base_plan_correction_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.payment_date IS NOT NULL + OR presence_budget_line_quarter.payment_amount <> 0 + OR presence_budget_line_quarter.payment_amount_ho <> 0 + OR presence_budget_line_quarter.payment_amount_rf <> 0 + OR presence_budget_line_quarter.payment_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.payment_act ~ '[^[:space:]]' + OR presence_budget_line_quarter.actual_m1 <> 0 + OR presence_budget_line_quarter.actual_m2 <> 0 + OR presence_budget_line_quarter.actual_m3 <> 0 + OR presence_budget_line_quarter.actual_spod <> 0 + OR presence_budget_line_quarter.transfer_to_q2 <> 0 + OR presence_budget_line_quarter.transfer_to_q3 <> 0 + OR presence_budget_line_quarter.transfer_to_q4 <> 0 + OR presence_budget_line_quarter.transfer_to_economy <> 0 + OR presence_budget_line_quarter.transfer_delay_acts <> 0 + OR presence_budget_line_quarter.transfer_delay_procurement <> 0 + OR presence_budget_line_quarter.transfer_economy_rf <> 0 + OR presence_budget_line_quarter.transfer_next_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.transfer_far_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.booking_amount <> 0 + OR presence_budget_line_quarter.plan_revision_eco_change <> 0 + OR presence_budget_line_quarter.plan_revision_item_adj <> 0 + OR presence_budget_line_quarter.plan_revision_increase <> 0 + OR presence_budget_line_quarter.plan_revision_sequester <> 0 + OR presence_budget_line_quarter.plan_revision_comment ~ '[^[:space:]]' + ) + OR ( + presence_rent_detail.contract_number ~ '[^[:space:]]' + OR presence_rent_detail.contract_end_date IS NOT NULL + OR presence_rent_detail.plan_q1 <> 0 + OR presence_rent_detail.plan_q2 <> 0 + OR presence_rent_detail.plan_q3 <> 0 + OR presence_rent_detail.plan_q4 <> 0 + OR presence_rent_detail.actual_jan <> 0 + OR presence_rent_detail.actual_feb <> 0 + OR presence_rent_detail.actual_mar <> 0 + OR presence_rent_detail.actual_apr <> 0 + OR presence_rent_detail.actual_may <> 0 + OR presence_rent_detail.actual_jun <> 0 + OR presence_rent_detail.actual_jul <> 0 + OR presence_rent_detail.actual_aug <> 0 + OR presence_rent_detail.actual_sep <> 0 + OR presence_rent_detail.actual_oct <> 0 + OR presence_rent_detail.actual_nov <> 0 + OR presence_rent_detail.actual_dec <> 0 + OR presence_rent_detail.comment ~ '[^[:space:]]' + ) + OR presence_security_detail.line_id IS NOT NULL + OR presence_utility_detail.line_id IS NOT NULL + ) + -- svod2_lines eligible: end +) +SELECT el.line_id, el.form_type_code, el.form_id, el.org_unit_id, el.org_name, + el.section_code, el.item_id, el.smeta_type, el.smeta_direction +FROM eligible_lines el +WHERE p_search IS NULL OR EXISTS ( + SELECT 1 + FROM v3.budget_line bl + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id + LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id + LEFT JOIN v3.ckk ck ON ck.line_id = bl.id + CROSS JOIN empty_sections e + CROSS JOIN LATERAL ( + SELECT + jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', el.form_type_code, 'source_id', el.form_id, + 'source_org_unit_id', el.org_unit_id, 'org_name', el.org_name, + 'smeta_type', el.smeta_type) AS header_j, + COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary) AS cs_j, + CASE WHEN p_sheet = 'RF' + THEN COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) || jsonb_build_object( + 'expenses_q1', COALESCE(cd.expenses_q1, ck.expenses_q1), + 'expenses_q2', COALESCE(cd.expenses_q2, ck.expenses_q2), + 'expenses_q3', COALESCE(cd.expenses_q3, ck.expenses_q3), + 'expenses_q4', COALESCE(cd.expenses_q4, ck.expenses_q4)) + ELSE COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) + END AS cd_j + ) s + CROSS JOIN LATERAL jsonb_each(s.header_j || s.cs_j || s.cd_j) kv + WHERE bl.id = el.line_id + AND strpos( + v3.svod2_casefold( + CASE jsonb_typeof(kv.value) + WHEN 'string' THEN kv.value #>> '{}' + WHEN 'number' THEN v3.svod2_js_number_text((kv.value #>> '{}')::float8) + WHEN 'boolean' THEN kv.value #>> '{}' + WHEN 'null' THEN '' + ELSE kv.value::text + END), + p_search) > 0 +) +$function$; + + +-- Страница свода 2 для ГО: поиск и три фильтра на сервере, total_count - число строк до LIMIT. +CREATE OR REPLACE FUNCTION v3.v_svod2_go( + p_year integer, + p_org_unit_ids integer[] DEFAULT NULL, + p_offset integer DEFAULT NULL, + p_limit integer DEFAULT NULL, + p_sort_by varchar DEFAULT 'org_name', + p_sort_direction varchar DEFAULT 'asc', + p_search text DEFAULT NULL, + p_smeta_type text DEFAULT NULL, + p_smeta_direction text DEFAULT NULL, + p_org_name text DEFAULT NULL, + p_compact boolean DEFAULT false +) +RETURNS TABLE( + source_form varchar, source_id integer, source_org_unit_id integer, + org_name varchar, smeta_type varchar, + source_line_id integer, sort_order bigint, data jsonb, total_count bigint +) +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + (to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('plan_year', NULL) AS plan, + (to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb)) + - 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration, + (to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('amount_year', NULL) AS reserve, + jsonb_build_object( + 'approved_q1', NULL, 'approved_q2', NULL, + 'approved_q3', NULL, 'approved_q4', NULL, + 'approved_year', NULL + ) AS approved, + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation, + to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial, + to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail, + to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking, + to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb)) + - 'line_id' - 'quarter' AS quarter +), numbered_lines AS ( + SELECT el.*, + count(*) OVER () AS total_count, + row_number() OVER ( + ORDER BY + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.org_name END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.org_name END DESC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.item_id END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.item_id END DESC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc' + THEN el.section_code END ASC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc' + THEN el.section_code END DESC NULLS LAST, + el.form_type_code, el.form_id, el.line_id + ) AS sort_order + FROM v3.svod2_lines('GO', p_year, p_org_unit_ids, + NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '')) el + WHERE (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_type)), '') IS NULL + OR (el.smeta_type IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.smeta_type)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_type)), ''))) + AND (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), '') IS NULL + OR (el.smeta_direction IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.smeta_direction)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), ''))) + AND (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), '') IS NULL + OR (el.org_name IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.org_name)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), ''))) +), page AS MATERIALIZED ( + SELECT * FROM numbered_lines + ORDER BY sort_order + LIMIT p_limit OFFSET COALESCE(p_offset, 0) +), booking_data AS ( + SELECT b.line_id, + jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking + FROM page pg + JOIN v3.booking b ON b.line_id = pg.line_id + GROUP BY b.line_id +) +SELECT x.source_form, x.source_id, x.source_org_unit_id, x.org_name, x.smeta_type, + x.source_line_id, x.sort_order, + CASE WHEN p_compact THEN v3.svod2_compact(x.full_data) ELSE x.full_data END AS data, + x.total_count +FROM ( + SELECT + pg.form_type_code AS source_form, + pg.form_id AS source_id, + pg.org_unit_id AS source_org_unit_id, + pg.org_name, + pg.smeta_type, + pg.line_id AS source_line_id, + pg.sort_order, + jsonb_build_object( + 'line_id', bl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', pg.form_type_code, 'source_id', pg.form_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', COALESCE( + (to_jsonb(pl) - 'line_id') || jsonb_build_object( + 'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)), + e.plan || jsonb_build_object('plan_year', NULL)), + 'sequestration', jsonb_build_object( + 'DFIP', COALESCE( + (to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL)), + 'SSP_GO', COALESCE( + (to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL))), + 'reserve', COALESCE( + (to_jsonb(rv) - 'line_id') || jsonb_build_object( + 'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)), + e.reserve || jsonb_build_object('amount_year', NULL)), + 'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL + AND rv.line_id IS NULL + THEN e.approved + ELSE jsonb_build_object( + 'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0) + + COALESCE(rv.amount_q1, 0), + 'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(rv.amount_q2, 0), + 'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0) + + COALESCE(rv.amount_q3, 0), + 'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q4, 0), + 'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0) + + COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END, + 'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary), + 'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation), + 'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial), + 'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk), + 'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail), + 'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)), + 'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q1, 0) > 0 THEN cd.expenses_q1 ELSE ck.expenses_q1 END + ELSE COALESCE(q1.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q1 + WHEN ck.expenses_q1 > 0 THEN ck.expenses_q1 + ELSE cd.expenses_q1 END) END), + 'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q2, 0) > 0 THEN cd.expenses_q2 ELSE ck.expenses_q2 END + ELSE COALESCE(q2.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q2 + WHEN ck.expenses_q2 > 0 THEN ck.expenses_q2 + ELSE cd.expenses_q2 END) END, + 'plan_revision_eco_change', COALESCE(q2.target_change, q2.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q2.base_plan_correction, q2.plan_revision_item_adj)), + 'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q3, 0) > 0 THEN cd.expenses_q3 ELSE ck.expenses_q3 END + ELSE COALESCE(q3.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q3 + WHEN ck.expenses_q3 > 0 THEN ck.expenses_q3 + ELSE cd.expenses_q3 END) END, + 'plan_revision_eco_change', COALESCE(q3.target_change, q3.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q3.base_plan_correction, q3.plan_revision_item_adj)), + 'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q4, 0) > 0 THEN cd.expenses_q4 ELSE ck.expenses_q4 END + ELSE COALESCE(q4.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q4 + WHEN ck.expenses_q4 > 0 THEN ck.expenses_q4 + ELSE cd.expenses_q4 END) END, + 'plan_revision_eco_change', COALESCE(q4.target_change, q4.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q4.base_plan_correction, q4.plan_revision_item_adj)) + ) AS full_data, + pg.total_count + FROM page pg + JOIN v3.budget_line bl ON bl.id = pg.line_id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id + CROSS JOIN empty_sections e + LEFT JOIN v3.plan pl ON pl.line_id = bl.id + LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP' + LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO' + LEFT JOIN v3.reserve rv ON rv.line_id = bl.id + LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id + LEFT JOIN v3.allocation al ON al.line_id = bl.id + LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id + LEFT JOIN v3.ckk ck ON ck.line_id = bl.id + LEFT JOIN booking_data bk ON bk.line_id = bl.id + LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1 + LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2 + LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3 + LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4 +) x +ORDER BY x.sort_order; +$function$; + +-- Страница свода 2 для РФ. +CREATE OR REPLACE FUNCTION v3.v_svod2_rf( + p_year integer, + p_org_unit_ids integer[] DEFAULT NULL, + p_offset integer DEFAULT NULL, + p_limit integer DEFAULT NULL, + p_sort_by varchar DEFAULT 'org_name', + p_sort_direction varchar DEFAULT 'asc', + p_search text DEFAULT NULL, + p_smeta_type text DEFAULT NULL, + p_smeta_direction text DEFAULT NULL, + p_org_name text DEFAULT NULL, + p_compact boolean DEFAULT false +) +RETURNS TABLE( + source_form varchar, source_id integer, source_org_unit_id integer, + org_name varchar, smeta_type varchar, + source_line_id integer, sort_order bigint, data jsonb, total_count bigint +) +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + (to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('plan_year', NULL) AS plan, + (to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb)) + - 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration, + (to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('amount_year', NULL) AS reserve, + jsonb_build_object( + 'approved_q1', NULL, 'approved_q2', NULL, + 'approved_q3', NULL, 'approved_q4', NULL, + 'approved_year', NULL + ) AS approved, + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation, + to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial, + to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail, + to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking, + to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb)) + - 'line_id' - 'quarter' AS budget_quarter +), numbered_lines AS ( + SELECT el.*, + count(*) OVER () AS total_count, + row_number() OVER ( + ORDER BY + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.org_name END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.org_name END DESC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.item_id END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.item_id END DESC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc' + THEN el.section_code END ASC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc' + THEN el.section_code END DESC NULLS LAST, + el.form_type_code, el.form_id, el.line_id + ) AS sort_order + FROM v3.svod2_lines('RF', p_year, p_org_unit_ids, + NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '')) el + WHERE (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_type)), '') IS NULL + OR (el.smeta_type IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.smeta_type)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_type)), ''))) + AND (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), '') IS NULL + OR (el.smeta_direction IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.smeta_direction)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), ''))) + AND (NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), '') IS NULL + OR (el.org_name IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(el.org_name)) + = NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), ''))) +), page AS MATERIALIZED ( + SELECT * FROM numbered_lines + ORDER BY sort_order + LIMIT p_limit OFFSET COALESCE(p_offset, 0) +), booking_data AS ( + SELECT b.line_id, + jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking + FROM page pg + JOIN v3.booking b ON b.line_id = pg.line_id + GROUP BY b.line_id +) +SELECT x.source_form, x.source_id, x.source_org_unit_id, x.org_name, x.smeta_type, + x.source_line_id, x.sort_order, + CASE WHEN p_compact THEN v3.svod2_compact(x.full_data) ELSE x.full_data END AS data, + x.total_count +FROM ( + SELECT + pg.form_type_code AS source_form, pg.form_id AS source_id, + pg.org_unit_id AS source_org_unit_id, pg.org_name, pg.smeta_type, + pg.line_id AS source_line_id, pg.sort_order, + jsonb_build_object( + 'line_id', bl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', pg.form_type_code, 'source_id', pg.form_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', COALESCE( + (to_jsonb(pl) - 'line_id') || jsonb_build_object( + 'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)), + e.plan || jsonb_build_object('plan_year', NULL)), + 'sequestration', jsonb_build_object( + 'DFIP', COALESCE( + (to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL)), + 'SSP_GO', COALESCE( + (to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL))), + 'reserve', COALESCE( + (to_jsonb(rv) - 'line_id') || jsonb_build_object( + 'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)), + e.reserve || jsonb_build_object('amount_year', NULL)), + 'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL + AND ss.id IS NULL AND rv.line_id IS NULL + THEN e.approved + ELSE jsonb_build_object( + 'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0) + + COALESCE(ss.adj_q1, 0) + COALESCE(rv.amount_q1, 0), + 'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(ss.adj_q2, 0) + COALESCE(rv.amount_q2, 0), + 'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(rv.amount_q3, 0), + 'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(ss.adj_q4, 0) + COALESCE(rv.amount_q4, 0), + 'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0) + + COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0) + + COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END, + 'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary), + 'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation), + 'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial), + 'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk), + 'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) || jsonb_build_object( + 'expenses_q1', COALESCE(cd.expenses_q1, ck.expenses_q1), + 'expenses_q2', COALESCE(cd.expenses_q2, ck.expenses_q2), + 'expenses_q3', COALESCE(cd.expenses_q3, ck.expenses_q3), + 'expenses_q4', COALESCE(cd.expenses_q4, ck.expenses_q4)), + 'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)), + 'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.budget_quarter) || jsonb_build_object( + 'booking_amount', COALESCE(q1.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q1 + WHEN ck.expenses_q1 > 0 THEN ck.expenses_q1 + ELSE cd.expenses_q1 END)), + 'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.budget_quarter) || jsonb_build_object( + 'booking_amount', COALESCE(q2.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q2 + WHEN ck.expenses_q2 > 0 THEN ck.expenses_q2 + ELSE cd.expenses_q2 END), + 'plan_revision_eco_change', COALESCE(q2.target_change, q2.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q2.base_plan_correction, q2.plan_revision_item_adj)), + 'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.budget_quarter) || jsonb_build_object( + 'booking_amount', COALESCE(q3.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q3 + WHEN ck.expenses_q3 > 0 THEN ck.expenses_q3 + ELSE cd.expenses_q3 END), + 'plan_revision_eco_change', COALESCE(q3.target_change, q3.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q3.base_plan_correction, q3.plan_revision_item_adj)), + 'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.budget_quarter) || jsonb_build_object( + 'booking_amount', COALESCE(q4.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q4 + WHEN ck.expenses_q4 > 0 THEN ck.expenses_q4 + ELSE cd.expenses_q4 END), + 'plan_revision_eco_change', COALESCE(q4.target_change, q4.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q4.base_plan_correction, q4.plan_revision_item_adj)) + ) AS full_data, + pg.total_count + FROM page pg + JOIN v3.budget_line bl ON bl.id = pg.line_id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id + CROSS JOIN empty_sections e + LEFT JOIN v3.plan pl ON pl.line_id = bl.id + LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP' + LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO' + LEFT JOIN v3.reserve rv ON rv.line_id = bl.id + LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id + LEFT JOIN v3.allocation al ON al.line_id = bl.id + LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id + LEFT JOIN v3.ckk ck ON ck.line_id = bl.id + LEFT JOIN booking_data bk ON bk.line_id = bl.id + LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1 + LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2 + LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3 + LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4 +) x +ORDER BY x.sort_order; +$function$; + +-- Варианты фильтров и счётчики: как TanStack, с поиском и фильтрами двух других колонок. +CREATE OR REPLACE FUNCTION v3.v_svod2_facets( + p_sheet varchar, + p_year integer, + p_org_unit_ids integer[], + p_search text, + p_smeta_type text, + p_smeta_direction text, + p_org_name text +) +RETURNS TABLE(kind varchar, value varchar, n bigint) +LANGUAGE sql STABLE +AS $function$ +WITH q AS MATERIALIZED ( + SELECT NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '') AS s, + NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_type)), '') AS f_type, + NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), '') AS f_dir, + NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), '') AS f_org +), lines AS MATERIALIZED ( + SELECT l.smeta_type, l.smeta_direction, l.org_name, + (q.s IS NULL OR l.line_id IN ( + SELECT m.line_id FROM v3.svod2_lines(p_sheet, p_year, p_org_unit_ids, q.s) m)) AS hit, + (q.f_type IS NULL OR (l.smeta_type IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(l.smeta_type)) = q.f_type)) AS ok_type, + (q.f_dir IS NULL OR (l.smeta_direction IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(l.smeta_direction)) = q.f_dir)) AS ok_dir, + (q.f_org IS NULL OR (l.org_name IS NOT NULL + AND v3.svod2_js_trim(v3.svod2_casefold(l.org_name)) = q.f_org)) AS ok_org + FROM v3.svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL) l + CROSS JOIN q +) +SELECT 'total'::varchar, NULL::varchar, count(*)::bigint FROM lines +UNION ALL +SELECT 'search_total'::varchar, NULL::varchar, count(*)::bigint FROM lines WHERE hit +UNION ALL +SELECT 'smeta_type'::varchar, g.smeta_type::varchar, count(*)::bigint +FROM lines g WHERE g.hit AND g.ok_dir AND g.ok_org AND g.smeta_type IS NOT NULL +GROUP BY g.smeta_type +UNION ALL +SELECT 'smeta_direction'::varchar, g.smeta_direction::varchar, count(*)::bigint +FROM lines g WHERE g.hit AND g.ok_type AND g.ok_org AND g.smeta_direction IS NOT NULL +GROUP BY g.smeta_direction +UNION ALL +SELECT 'org_name'::varchar, g.org_name::varchar, count(*)::bigint +FROM lines g WHERE g.hit AND g.ok_type AND g.ok_dir AND g.org_name IS NOT NULL +GROUP BY g.org_name +$function$; diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index 4944dc4..b403ae0 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -51,3 +51,70 @@ def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_ full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"] got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0][0] assert got == compact_projection(full) + + +def test_single_signature_without_overloads(svod2_sql): + rows = svod2_sql( + "SELECT proname, pronargs FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace " + "WHERE nspname = 'v3' AND proname IN ('v_svod2_go', 'v_svod2_rf', 'v_svod2_go_export')", + None, + ) + assert sorted((r.proname, r.pronargs) for r in rows) == [ + ("v_svod2_go", 11), ("v_svod2_go_export", 4), ("v_svod2_rf", 11)] + + +@pytest.mark.parametrize("fn", ["v_svod2_go", "v_svod2_rf"]) +def test_page_total_count_and_order(svod2_sql, svod2_data, admin_tokens, fn): + # admin_tokens: запрос к API нужен, чтобы откат сессии клиента сработал (loop_ref задаётся при обращении к get_db) + full = svod2_sql(f"SELECT source_line_id FROM v3.{fn}(2031)", None) + page = svod2_sql( + f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, 0, 2, 'org_name', 'asc', " + "NULL, NULL, NULL, NULL, false)", None) + assert [r.source_line_id for r in page] == [r.source_line_id for r in full][:2] + assert {r.total_count for r in page} == {len(full)} + + +def test_facets_totals(svod2_sql, svod2_data, admin_tokens): + rows = {(r.kind, r.value): r.n for r in svod2_sql( + "SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, NULL, NULL, NULL, NULL)", None)} + assert rows[("total", None)] == rows[("search_total", None)] == len( + svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None)) + + +SEARCHES = ["", "аренда", "АРЕНДА", "ё", "1234.5", "1234.50", " кв ", "1", "true", "777.25", "нет такого"] + + +@pytest.mark.parametrize("sheet,fn", [("GO", "v_svod2_go"), ("RF", "v_svod2_rf")]) +@pytest.mark.parametrize("search", SEARCHES) +def test_search_matches_screen_oracle(svod2_sql, svod2_data, client, admin_tokens, auth_headers, + sheet, fn, search): + from tests.svod2_oracle import screen_rows + + rows = legacy_full(client, sheet, auth_headers(admin_tokens)) + expected = [r["data"]["line_id"] for r in screen_rows(rows, search)] + got = svod2_sql( + f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, NULL, NULL, 'org_name', 'asc', :s)", + {"s": search or None}, + ) + assert sorted(r.source_line_id for r in got) == sorted(expected) + if got: + assert {r.total_count for r in got} == {len(expected)} + + +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +@pytest.mark.parametrize("search,smeta_type", [("", None), ("1", "кв "), ("аренда", "АХР"), ("", "Операц")]) +def test_facets_match_screen_oracle(svod2_sql, svod2_data, client, admin_tokens, auth_headers, + sheet, search, smeta_type): + from tests.svod2_oracle import screen_facets + + rows = legacy_full(client, sheet, auth_headers(admin_tokens)) + filters = {"smeta_type": smeta_type} if smeta_type else {} + expected = screen_facets(rows, search, filters) + got = svod2_sql( + "SELECT kind, value, n FROM v3.v_svod2_facets(:sh, 2031, NULL, :s, :t, NULL, NULL)", + {"sh": sheet, "s": search or None, "t": smeta_type}, + ) + assert next(r.n for r in got if r.kind == "total") == expected["total"] + assert next(r.n for r in got if r.kind == "search_total") == expected["search_total"] + for col in ("smeta_type", "smeta_direction", "org_name"): + assert {r.value: r.n for r in got if r.kind == col} == expected[col] diff --git a/api/tests/unit/test_summary2_related_records_sql.py b/api/tests/unit/test_summary2_related_records_sql.py index 04bfd39..c403dd5 100644 --- a/api/tests/unit/test_summary2_related_records_sql.py +++ b/api/tests/unit/test_summary2_related_records_sql.py @@ -6,7 +6,9 @@ import unittest from pathlib import Path -SQL_PATH = Path(__file__).resolve().parents[2] / "alembic/versions/sql/0049_summary2_related_records.sql" +SQL_DIR = Path(__file__).resolve().parents[2] / "alembic/versions/sql" +SQL_PATH = SQL_DIR / "0053_svod2_server_paging.sql" +EXPORT_SQL_PATH = SQL_DIR / "0049_summary2_related_records.sql" TABLES = ( "plan", "reserve", "contract_summary", "allocation", "collegial_approval", "ckk", "contract_detail", "sequestration", "booking", "budget_line_quarter", @@ -59,8 +61,14 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase): self.db.execute(f"CREATE TABLE v3.{table} ({columns})") # Execute the eligibility SELECTs, adapting PostgreSQL casts, arrays and regex syntax. self.queries = [] - for part in SQL_PATH.read_text().split("eligible_lines AS (")[1:]: - query = part.split("), ", 1)[0].replace("::varchar", "") + text = SQL_PATH.read_text() + match = re.search(r"-- svod2_lines eligible: begin\n(.*?)\n\s*-- svod2_lines eligible: end", text, re.S) + self.assertIsNotNone(match) + bodies = [match.group(1).replace("p_sheet", f"'{sheet}'") for sheet in ("GO", "RF")] + # Экспорт ГО по-прежнему из 0049: его отбор эта миграция не меняет. + export = EXPORT_SQL_PATH.read_text().split("eligible_lines AS (")[-1].split("), ", 1)[0] + for query in bodies + [export]: + query = query.replace("::varchar", "") query = re.sub(r"(presence_\w+\.\w+) ~ '\[\^\[:space:\]\]'", r"has_text(\1)", query) query = query.replace("p_year", ":year") -- 2.47.2 From e81df1d71e9a298915160cd87746eb6ee146c16e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:50:04 +0400 Subject: [PATCH 11/17] =?UTF-8?q?fix(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0,=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=81=D0=B5=D0=B2=D0=B0=20=D0=B2=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0053_svod2_server_paging.sql | 5 ++++- api/tests/integration/conftest.py | 5 ++++- api/tests/integration/test_svod2_sql.py | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/api/alembic/versions/sql/0053_svod2_server_paging.sql b/api/alembic/versions/sql/0053_svod2_server_paging.sql index e30e602..d2162c2 100644 --- a/api/alembic/versions/sql/0053_svod2_server_paging.sql +++ b/api/alembic/versions/sql/0053_svod2_server_paging.sql @@ -879,9 +879,12 @@ WITH q AS MATERIALIZED ( NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_smeta_direction)), '') AS f_dir, NULLIF(v3.svod2_js_trim(v3.svod2_casefold(p_org_name)), '') AS f_org ), lines AS MATERIALIZED ( + -- Подзапрос IN не должен ссылаться на столбцы q/l (иначе он коррелированный и считается + -- на каждую строку): параметр нормализуется заново, тогда это hashed SubPlan, один раз. SELECT l.smeta_type, l.smeta_direction, l.org_name, (q.s IS NULL OR l.line_id IN ( - SELECT m.line_id FROM v3.svod2_lines(p_sheet, p_year, p_org_unit_ids, q.s) m)) AS hit, + SELECT m.line_id FROM v3.svod2_lines(p_sheet, p_year, p_org_unit_ids, + NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), '')) m)) AS hit, (q.f_type IS NULL OR (l.smeta_type IS NOT NULL AND v3.svod2_js_trim(v3.svod2_casefold(l.smeta_type)) = q.f_type)) AS ok_type, (q.f_dir IS NULL OR (l.smeta_direction IS NOT NULL diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 6879c10..da6ba76 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -123,7 +123,7 @@ def svod2_data(client, svod2_sql) -> dict: # id строки contract_detail: contract_detail.line_id = 910002, технический id, # который попадает в шапку строки, — expense_item_id этой строки cd_id = svod2_sql("SELECT expense_item_id FROM v3.budget_line WHERE id = 910002")[0][0] - return { + yield { "year": SVOD2_YEAR, "org_a": 910001, "org_b": 910002, @@ -132,3 +132,6 @@ def svod2_data(client, svod2_sql) -> dict: "cd_id": cd_id, "ckk_only_value": "777.25", } + # откат, пока portal открыт: иначе при отсутствии запросов к API транзакция засева + # остаётся висеть и блокирует следующий засев + client.portal.call(client.db_session.rollback) diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index b403ae0..c51da20 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -94,7 +94,7 @@ def test_search_matches_screen_oracle(svod2_sql, svod2_data, client, admin_token expected = [r["data"]["line_id"] for r in screen_rows(rows, search)] got = svod2_sql( f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, NULL, NULL, 'org_name', 'asc', :s)", - {"s": search or None}, + {"s": search}, ) assert sorted(r.source_line_id for r in got) == sorted(expected) if got: @@ -118,3 +118,17 @@ def test_facets_match_screen_oracle(svod2_sql, svod2_data, client, admin_tokens, assert next(r.n for r in got if r.kind == "search_total") == expected["search_total"] for col in ("smeta_type", "smeta_direction", "org_name"): assert {r.value: r.n for r in got if r.kind == col} == expected[col] + + +def test_facets_search_subplan_is_not_correlated(svod2_sql): + import re + + plan = "\n".join(r[0] for r in svod2_sql( + "EXPLAIN (VERBOSE, COSTS OFF) " + "SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, 'аренда', NULL, NULL, NULL)", None)) + # IN по svod2_lines должен быть hashed SubPlan (считается один раз); коррелированный + # считался бы на каждую строку. Не-hashed SubPlan в Filter внутри svod2_lines (EXISTS поиска) + # законен, поэтому смотрим только на выражения в Output. + outputs = [l for l in plan.splitlines() if "Output:" in l and "SubPlan" in l] + assert any("hashed SubPlan" in l for l in outputs), plan + assert not any(re.search(r"(? Date: Wed, 28 Oct 2026 21:50:27 +0400 Subject: [PATCH 12/17] =?UTF-8?q?test(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B5=20=D1=81=20=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=83=D0=BB=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/integration/test_svod2_sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index c51da20..0459868 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -81,7 +81,7 @@ def test_facets_totals(svod2_sql, svod2_data, admin_tokens): svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None)) -SEARCHES = ["", "аренда", "АРЕНДА", "ё", "1234.5", "1234.50", " кв ", "1", "true", "777.25", "нет такого"] +SEARCHES = ["", " ", "аренда", "АРЕНДА", "ё", "1234.5", "1234.50", " кв ", "1", "true", "777.25", "нет такого"] @pytest.mark.parametrize("sheet,fn", [("GO", "v_svod2_go"), ("RF", "v_svod2_rf")]) -- 2.47.2 From e3a9a08e84018fbe714ca51593465bf4319519c7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:53:16 +0400 Subject: [PATCH 13/17] =?UTF-8?q?perf(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/schemas.py | 17 ++++ api/src/repository/summary_repository.py | 116 +++++++++++++++++++++++ api/src/services/summary_service.py | 77 +++++++++++++++ api/tests/unit/test_summary2_service.py | 79 +++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 api/tests/unit/test_summary2_service.py diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index c7ef75c..e7e9302 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -361,6 +361,23 @@ class SheetResponse(BaseModel): sort_order: int | None = None +class Svod2PageResponse(BaseListResponse[SheetResponse]): + total: int + + +class Svod2FacetValue(BaseModel): + value: str + count: int + + +class Svod2Facets(BaseModel): + total: int + search_total: int + smeta_type: list[Svod2FacetValue] + smeta_direction: list[Svod2FacetValue] + org_name: list[Svod2FacetValue] + + class CellPatch(BaseModel): line_id: int column: str diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 88285bb..3d3ed7f 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -76,6 +76,122 @@ class SummaryRepository: ) ).all() + async def get_svod2_page( + self, + year: int, + sheet: str, + org_ids: list[int] | None, + offset: int | None, + limit: int | None, + sort_by: str, + sort_direction: str, + search: str | None, + smeta_type: str | None, + smeta_direction: str | None, + org_name: str | None, + compact: bool, + ) -> tuple[list[tuple], int]: + function_name = { + "GO": "v3.v_svod2_go", + "RF": "v3.v_svod2_rf", + }[sheet] + query = text( + f""" + SELECT + 'INPUT'::VARCHAR AS row_type, + 3 AS depth, + sort_order, + data, + total_count + FROM {function_name}( + CAST(:year AS INT), + CAST(:org_ids AS INT[]), + CAST(:offset AS INT), + CAST(:limit AS INT), + CAST(:sort_by AS VARCHAR), + CAST(:sort_direction AS VARCHAR), + CAST(:search AS TEXT), + CAST(:smeta_type AS TEXT), + CAST(:smeta_direction AS TEXT), + CAST(:org_name AS TEXT), + CAST(:compact AS BOOLEAN) + ) + """ + ) + result = ( + await self.db.execute( + query, + { + "year": year, + "org_ids": org_ids, + "offset": offset, + "limit": limit, + "sort_by": sort_by, + "sort_direction": sort_direction, + "search": search, + "smeta_type": smeta_type, + "smeta_direction": smeta_direction, + "org_name": org_name, + "compact": compact, + }, + ) + ).all() + total = int(result[0][4]) if result else 0 + return [tuple(row[:4]) for row in result], total + + async def get_svod2_facets( + self, + year: int, + sheet: str, + org_ids: list[int] | None, + search: str | None, + smeta_type: str | None, + smeta_direction: str | None, + org_name: str | None, + ) -> dict: + query = text( + """ + SELECT kind, value, n + FROM v3.v_svod2_facets( + CAST(:sheet AS VARCHAR), + CAST(:year AS INT), + CAST(:org_ids AS INT[]), + CAST(:search AS TEXT), + CAST(:smeta_type AS TEXT), + CAST(:smeta_direction AS TEXT), + CAST(:org_name AS TEXT) + ) + ORDER BY kind, value + """ + ) + rows = ( + await self.db.execute( + query, + { + "sheet": sheet, + "year": year, + "org_ids": org_ids, + "search": search, + "smeta_type": smeta_type, + "smeta_direction": smeta_direction, + "org_name": org_name, + }, + ) + ).all() + facets: dict = { + "total": 0, + "search_total": 0, + "smeta_type": [], + "smeta_direction": [], + "org_name": [], + } + for kind, value, count in rows: + if kind in ("total", "search_total"): + facets[kind] = int(count) + elif kind in facets: + facets[kind].append({"value": value, "count": int(count)}) + return facets + async def get_svod2_go_export_rows( self, year: int, diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 1408e04..4cf31ba 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -1,3 +1,5 @@ +import copy + from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.app_user import AppUser @@ -6,6 +8,15 @@ from src.repository.summary_repository import SummaryRepository from src.repository.user_repository import UserRepository +EMPTY_SVOD2_FACETS = { + "total": 0, + "search_total": 0, + "smeta_type": [], + "smeta_direction": [], + "org_name": [], +} + + class SummaryService: def __init__(self, db: AsyncSession): self.db = db @@ -52,6 +63,72 @@ class SummaryService: sort_direction=sort_direction, ) + async def get_svod2_page( + self, + year: int, + sheet: str, + user: AppUser, + org_ids: list[int] | None = None, + offset: int | None = None, + limit: int | None = None, + sort_by: str = "org_name", + sort_direction: str = "asc", + search: str | None = None, + smeta_type: str | None = None, + smeta_direction: str | None = None, + org_name: str | None = None, + view: str = "full", + ) -> tuple[list[tuple], int]: + org_ids = await self._allowed_org_ids(user, org_ids) + if org_ids == []: + return [], 0 + + async def fetch(page_offset: int | None, page_limit: int | None): + return await self.summary_repo.get_svod2_page( + year=year, + sheet=sheet, + org_ids=org_ids, + offset=page_offset, + limit=page_limit, + sort_by=sort_by, + sort_direction=sort_direction, + search=search, + smeta_type=smeta_type, + smeta_direction=smeta_direction, + org_name=org_name, + compact=view == "compact", + ) + + rows, total = await fetch(offset, limit) + if not rows and offset: + # Страница за концом выборки: total берётся из первой строки, которой нет. + _, total = await fetch(0, 1) + return rows, total + + async def get_svod2_facets( + self, + year: int, + sheet: str, + user: AppUser, + org_ids: list[int] | None = None, + search: str | None = None, + smeta_type: str | None = None, + smeta_direction: str | None = None, + org_name: str | None = None, + ) -> dict: + org_ids = await self._allowed_org_ids(user, org_ids) + if org_ids == []: + return copy.deepcopy(EMPTY_SVOD2_FACETS) + return await self.summary_repo.get_svod2_facets( + year=year, + sheet=sheet, + org_ids=org_ids, + search=search, + smeta_type=smeta_type, + smeta_direction=smeta_direction, + org_name=org_name, + ) + async def get_svod2_go_export_rows( self, year: int, diff --git a/api/tests/unit/test_summary2_service.py b/api/tests/unit/test_summary2_service.py new file mode 100644 index 0000000..8d70540 --- /dev/null +++ b/api/tests/unit/test_summary2_service.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.services.summary_service import EMPTY_SVOD2_FACETS, SummaryService + +ROW = ("INPUT", 3, 1, {"line_id": 1}) + + +def make_service(monkeypatch, allowed): + service = SummaryService.__new__(SummaryService) + service.summary_repo = AsyncMock() + service.user_repo = AsyncMock() + monkeypatch.setattr(service, "_allowed_org_ids", AsyncMock(return_value=allowed)) + return service + + +USER = SimpleNamespace(id=1) + + +async def test_page_passes_params_and_compact_flag(monkeypatch): + service = make_service(monkeypatch, [1, 2]) + service.summary_repo.get_svod2_page.return_value = ([ROW], 7) + result = await service.get_svod2_page( + 2026, "GO", USER, org_ids=[1, 2], offset=0, limit=50, + sort_by="name", sort_direction="desc", search="x", + smeta_type="КВ", smeta_direction="Support", org_name="Орг", view="compact", + ) + assert result == ([ROW], 7) + service.summary_repo.get_svod2_page.assert_awaited_once_with( + year=2026, sheet="GO", org_ids=[1, 2], offset=0, limit=50, + sort_by="name", sort_direction="desc", search="x", + smeta_type="КВ", smeta_direction="Support", org_name="Орг", compact=True, + ) + + +async def test_page_empty_intersection_skips_sql(monkeypatch): + service = make_service(monkeypatch, []) + assert await service.get_svod2_page(2026, "GO", USER, offset=0, limit=10) == ([], 0) + service.summary_repo.get_svod2_page.assert_not_awaited() + + +async def test_page_past_end_second_call_for_total(monkeypatch): + service = make_service(monkeypatch, None) + service.summary_repo.get_svod2_page.side_effect = [([], 0), ([ROW], 42)] + result = await service.get_svod2_page(2026, "GO", USER, offset=600, limit=50) + assert result == ([], 42) + second = service.summary_repo.get_svod2_page.await_args_list[1].kwargs + assert second["offset"] == 0 and second["limit"] == 1 + + +async def test_page_empty_first_page_no_second_call(monkeypatch): + service = make_service(monkeypatch, None) + service.summary_repo.get_svod2_page.return_value = ([], 0) + assert await service.get_svod2_page(2026, "GO", USER, offset=0, limit=50) == ([], 0) + assert service.summary_repo.get_svod2_page.await_count == 1 + + +async def test_facets_empty_intersection_zeros_without_sql(monkeypatch): + service = make_service(monkeypatch, []) + result = await service.get_svod2_facets(2026, "GO", USER, org_ids=[5]) + assert result == EMPTY_SVOD2_FACETS + assert result is not EMPTY_SVOD2_FACETS + service.summary_repo.get_svod2_facets.assert_not_awaited() + + +async def test_facets_passes_allowed_org_ids_and_filters(monkeypatch): + service = make_service(monkeypatch, [1]) + service.summary_repo.get_svod2_facets.return_value = {"total": 3} + result = await service.get_svod2_facets( + 2026, "RF", USER, org_ids=[1, 9], search="x", smeta_type="КВ", + smeta_direction="Support", org_name="Орг", + ) + assert result == {"total": 3} + service.summary_repo.get_svod2_facets.assert_awaited_once_with( + year=2026, sheet="RF", org_ids=[1], search="x", smeta_type="КВ", + smeta_direction="Support", org_name="Орг", + ) -- 2.47.2 From db28138b5bfa2b56a8d3ac4a706523cbeb477ae3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:56:34 +0400 Subject: [PATCH 14/17] =?UTF-8?q?perf(api):=20=D1=81=D0=B2=D0=BE=D0=B4=202?= =?UTF-8?q?=20=E2=80=94=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=20=D1=81=20total=20=D0=B8=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/summary.py | 147 +++++++++++++++++++++++---- api/tests/unit/test_summary2_mode.py | 10 ++ 2 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 api/tests/unit/test_summary2_mode.py diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index 84b2f8d..679fd1d 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -8,7 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.v1.deps import get_current_user from src.db.models.app_user import AppUser from src.db.session import get_db -from src.domain.schemas import BaseListResponse, SheetResponse +from src.domain.schemas import ( + BaseListResponse, + BaseSingleResponse, + SheetResponse, + Svod2Facets, + Svod2PageResponse, +) from src.services.summary_service import SummaryService @@ -90,7 +96,33 @@ async def get_summary( ) -@router.get("/2/{sheet}/{year}") +def is_svod2_page_mode( + view: Optional[str], + search: Optional[str], + smeta_type: Optional[str], + smeta_direction: Optional[str], + org_name: Optional[str], +) -> bool: + return any( + value is not None + for value in (view, search, smeta_type, smeta_direction, org_name) + ) + + +def _check_summary2_sheet(sheet: str) -> str: + sheet = sheet.upper() + if sheet not in VALID_SUMMARY2_SHEETS: + raise HTTPException( + 404, + f"Лист свода {sheet} должен быть одним из: {', '.join(sorted(VALID_SUMMARY2_SHEETS))}", + ) + return sheet + + +@router.get( + "/2/{sheet}/{year}", + response_model=Svod2PageResponse | BaseListResponse[SheetResponse], +) async def get_summary2( sheet: str, year: int, @@ -100,28 +132,47 @@ async def get_summary2( limit: Optional[int] = Query(default=None, ge=1), sort_by: Literal["org_name", "section_code"] = "org_name", sort_direction: Literal["asc", "desc"] = "asc", + view: Optional[Literal["full", "compact"]] = None, + search: Optional[str] = None, + smeta_type: Optional[str] = None, + smeta_direction: Optional[str] = None, + org_name: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_user), -) -> BaseListResponse[SheetResponse]: - sheet = sheet.upper() - if sheet not in VALID_SUMMARY2_SHEETS: - raise HTTPException( - 404, - f"Лист свода {sheet} должен быть одним из: {', '.join(sorted(VALID_SUMMARY2_SHEETS))}", - ) +) -> Svod2PageResponse | BaseListResponse[SheetResponse]: + sheet = _check_summary2_sheet(sheet) + page_mode = is_svod2_page_mode(view, search, smeta_type, smeta_direction, org_name) t0 = time.perf_counter() summary_service = SummaryService(db) - rows = await summary_service.get_svod2_rows( - year=year, - sheet=sheet, - user=current_user, - org_ids=_parse_org_ids(org_ids), - offset=offset, - limit=limit, - sort_by=sort_by, - sort_direction=sort_direction, - ) + total = None + if page_mode: + rows, total = await summary_service.get_svod2_page( + year=year, + sheet=sheet, + user=current_user, + org_ids=_parse_org_ids(org_ids), + offset=offset, + limit=limit, + sort_by=sort_by, + sort_direction=sort_direction, + search=search, + smeta_type=smeta_type, + smeta_direction=smeta_direction, + org_name=org_name, + view=view or "full", + ) + else: + rows = await summary_service.get_svod2_rows( + year=year, + sheet=sheet, + user=current_user, + org_ids=_parse_org_ids(org_ids), + offset=offset, + limit=limit, + sort_by=sort_by, + sort_direction=sort_direction, + ) db_ms = (time.perf_counter() - t0) * 1000 response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" @@ -136,11 +187,69 @@ async def get_summary2( "limit": limit, "sort_by": sort_by, "sort_direction": sort_direction, + "view": view, + "search": search, + "smeta_type": smeta_type, + "smeta_direction": smeta_direction, + "org_name": org_name, "user": current_user.id, } logger.info(f"db_time: {extra}", extra=extra) + if page_mode: + return Svod2PageResponse( + count=len(rows), + result=_rows_to_sheet_response(rows), + total=total, + ) return BaseListResponse( count=len(rows), result=_rows_to_sheet_response(rows), ) + + +@router.get("/2/{sheet}/{year}/facets") +async def get_summary2_facets( + sheet: str, + year: int, + response: Response, + org_ids: Optional[str] = None, + search: Optional[str] = None, + smeta_type: Optional[str] = None, + smeta_direction: Optional[str] = None, + org_name: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_user), +) -> BaseSingleResponse[Svod2Facets]: + sheet = _check_summary2_sheet(sheet) + + t0 = time.perf_counter() + facets = await SummaryService(db).get_svod2_facets( + year=year, + sheet=sheet, + user=current_user, + org_ids=_parse_org_ids(org_ids), + search=search, + smeta_type=smeta_type, + smeta_direction=smeta_direction, + org_name=org_name, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + + extra = { + "type": "db_time", + "time_ms": f"{db_ms:.2f}", + "handler": "get_summary2_facets", + "sheet": sheet, + "year": year, + "org_ids": org_ids, + "search": search, + "smeta_type": smeta_type, + "smeta_direction": smeta_direction, + "org_name": org_name, + "user": current_user.id, + } + logger.info(f"db_time: {extra}", extra=extra) + + return BaseSingleResponse(result=facets) diff --git a/api/tests/unit/test_summary2_mode.py b/api/tests/unit/test_summary2_mode.py new file mode 100644 index 0000000..2d95d29 --- /dev/null +++ b/api/tests/unit/test_summary2_mode.py @@ -0,0 +1,10 @@ +import pytest + +from src.api.v1.summary import is_svod2_page_mode + + +@pytest.mark.parametrize("args,expected", [ + ((None, None, None, None, None), False), (("full", None, None, None, None), True), + ((None, "", None, None, None), True), ((None, None, None, None, "x"), True)]) +def test_is_svod2_page_mode(args, expected): + assert is_svod2_page_mode(*args) is expected -- 2.47.2 From 100dba4a90fa9503ecd0290aae6c51c6b9b125d3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:10:42 +0400 Subject: [PATCH 15/17] =?UTF-8?q?docs(openspec):=20aurora-1843=20=E2=80=94?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=201=20(R-5..R-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/aurora-1843-svod2-server-paging/design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openspec/changes/aurora-1843-svod2-server-paging/design.md b/openspec/changes/aurora-1843-svod2-server-paging/design.md index c180b18..4093c47 100644 --- a/openspec/changes/aurora-1843-svod2-server-paging/design.md +++ b/openspec/changes/aurora-1843-svod2-server-paging/design.md @@ -682,3 +682,13 @@ PR создаются по готовности, merge — по команде Цена ошибки: отложенное обнаружение, каскадный rebase. 38. **2026-10-28 (контроллер, R-4) — в коммитах исполнителей остаётся трейлер `Co-Authored-By: Claude Opus 5.5 ` из плана и правил волны 2.** Цена ошибки: amend/rebase трейлера. +39. **2026-10-28 (контроллер, R-5) — I-1 финального ревью фазы 1 (серверный поиск медленнее экрана «до»: `translate`-casefold и plpgsql-запись чисел на каждое значение, ~1,46 с на 10 тыс. строк, предложено регулярное выражение из `svod2_search_pattern`) не правится в фазе 1, статус `deferred`. Причина: переписывание предиката меняет доказанную оракулами реализацию поиска; цифры должен дать замер главного контроллера на стенде (план, Task 18 Step 6). Миграция 0053 ещё не выкачена, поэтому правка возможна в ней же до слияния.** + Обоснование: решение 11 — цели по времени не условие слияния; корректность важнее. Требование человека (поведение прежнее) не страдает. + Цена ошибки: если замер подтвердит недобор — переписать предикат в 0053 (оракулы и векторы остаются), порядка полудня. Одновременно закрывается T5.5. +40. **2026-10-28 (контроллер, R-6) — `v_svod2_facets` считает `svod2_lines` дважды: без поиска (все строки) и `IN (hashed)` по второму вызову с поиском. Design §4 описывал одну выборку с флагом совпадения; отклонение сделано в fix-раунде Task 5 (I-1 ревью Task 5: коррелированный подзапрос давал N²).** + Цена ошибки: двойной отбор при поиске в facets; влияет на время, не на корректность. Измерит контроллер. +41. **2026-10-28 (контроллер, R-7) — значения `search`, `smeta_type`, `smeta_direction`, `org_name` не пишутся в лог `db_time` (M-2): только признаки и длина.** + Обоснование: пользовательский текст (контрагенты, договоры) без ограничения длины не должен попадать в журналы. + Цена ошибки: нет. +42. **2026-10-28 (контроллер, R-8) — M-4 (символ NUL в `search`/фильтре даёт ошибку типа `text` PostgreSQL, вероятно 5xx) не правится в этой задаче, `deferred`; с экрана такой символ не вводится.** + Цена ошибки: 5xx на искусственном запросе; правка — ранний возврат пустой страницы. -- 2.47.2 From 782a04381bf8c4a583f403cf2b7eae774a5c055b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:12:14 +0400 Subject: [PATCH 16/17] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=84=D0=B0=D0=B7=D1=8B=201:?= =?UTF-8?q?=20M-1,=20M-2,=20M-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M-1: поиск по перекрытым полям contract_summary (ооо лютик, дс-2, дс-1) и неперекрытому (70), явные assert для строки 910004 - M-2: в лог db_time вместо значений поиска и фильтров пишутся признаки has_*/search_len; unit-тест на caplog - M-5: total читается по имени столбца total_count Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/summary.py | 18 +++--- api/src/repository/summary_repository.py | 2 +- .../integration/test_svod2_paging_api.py | 30 ++++++++++ api/tests/unit/test_summary2_log_privacy.py | 60 +++++++++++++++++++ 4 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit/test_summary2_log_privacy.py diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index 679fd1d..a2ed152 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -188,10 +188,11 @@ async def get_summary2( "sort_by": sort_by, "sort_direction": sort_direction, "view": view, - "search": search, - "smeta_type": smeta_type, - "smeta_direction": smeta_direction, - "org_name": org_name, + "has_search": bool(search), + "search_len": len(search or ""), + "has_smeta_type": bool(smeta_type), + "has_smeta_direction": bool(smeta_direction), + "has_org_name": bool(org_name), "user": current_user.id, } logger.info(f"db_time: {extra}", extra=extra) @@ -244,10 +245,11 @@ async def get_summary2_facets( "sheet": sheet, "year": year, "org_ids": org_ids, - "search": search, - "smeta_type": smeta_type, - "smeta_direction": smeta_direction, - "org_name": org_name, + "has_search": bool(search), + "search_len": len(search or ""), + "has_smeta_type": bool(smeta_type), + "has_smeta_direction": bool(smeta_direction), + "has_org_name": bool(org_name), "user": current_user.id, } logger.info(f"db_time: {extra}", extra=extra) diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 3d3ed7f..75d23b8 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -136,7 +136,7 @@ class SummaryRepository: }, ) ).all() - total = int(result[0][4]) if result else 0 + total = int(result[0].total_count) if result else 0 return [tuple(row[:4]) for row in result], total async def get_svod2_facets( diff --git a/api/tests/integration/test_svod2_paging_api.py b/api/tests/integration/test_svod2_paging_api.py index 795c1b5..8fb4bf3 100644 --- a/api/tests/integration/test_svod2_paging_api.py +++ b/api/tests/integration/test_svod2_paging_api.py @@ -18,6 +18,7 @@ SMETA_TYPE_VARIANT = SMETA_TYPE.lower() + " " SEARCHES = [ "аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда ", " ", "1", ",", "777.25", "", "я" * 5000, + "ооо лютик", "дс-2", "дс-1", "70", ] FILTERS = { @@ -140,6 +141,35 @@ def test_search_matches_screen_oracle(client, svod2_data, headers, sheet, search assert all(t == len(expected) for t in totals) +# Строка 910004 имеет contract_summary (ООО Лютик, ДС-2, 70), но НЕ имеет contract_detail: +# пустая секция contract_detail перекрывает counterparty/reference из contract_summary +# (семантика экрана), а неперекрытое поле total_amount остаётся доступным для поиска. +LINE_NO_DETAIL = 910004 + + +@pytest.mark.parametrize("sheet", SHEETS) +@pytest.mark.parametrize("search", ["ооо лютик", "дс-2"]) +def test_search_overlapped_contract_summary_field_not_found( + client, svod2_data, headers, sheet, search +): + full = legacy_full(client, sheet, headers) + expected = screen_rows(full, search) + assert LINE_NO_DETAIL not in _line_ids(expected) + rows, _ = _all_pages(client, sheet, headers, 3, view="compact", search=search) + assert LINE_NO_DETAIL not in _line_ids(rows) + + +@pytest.mark.parametrize("sheet", ["GO"]) +def test_search_non_overlapped_contract_summary_field_found( + client, svod2_data, headers, sheet +): + full = legacy_full(client, sheet, headers) + expected = screen_rows(full, "70") + assert LINE_NO_DETAIL in _line_ids(expected) + rows, _ = _all_pages(client, sheet, headers, 3, view="compact", search="70") + assert LINE_NO_DETAIL in _line_ids(rows) + + @pytest.mark.parametrize("sheet", SHEETS) @pytest.mark.parametrize("name", list(FILTERS)) def test_filters_match_screen_oracle(client, svod2_data, headers, sheet, name): diff --git a/api/tests/unit/test_summary2_log_privacy.py b/api/tests/unit/test_summary2_log_privacy.py new file mode 100644 index 0000000..f888aa9 --- /dev/null +++ b/api/tests/unit/test_summary2_log_privacy.py @@ -0,0 +1,60 @@ +"""Лог db_time не содержит значений поиска и фильтров (только признаки).""" +import logging +from types import SimpleNamespace + +import pytest +from fastapi import Response + +from src.api.v1 import summary as summary_api + +SECRET = "СЕКРЕТНЫЙ-ПОИСК-123" +FILTERS = {"smeta_type": "СЕКРЕТ-ТИП", "smeta_direction": "СЕКРЕТ-НАПР", "org_name": "СЕКРЕТ-ОРГ"} + + +class _FakeService: + def __init__(self, db): + pass + + async def get_svod2_page(self, **kwargs): + return [], 0 + + async def get_svod2_facets(self, **kwargs): + return {} + + +@pytest.fixture(autouse=True) +def _fake_service(monkeypatch): + monkeypatch.setattr(summary_api, "SummaryService", _FakeService) + monkeypatch.setattr(summary_api, "Svod2Facets", lambda **kw: kw, raising=False) + + +def _assert_no_values(caplog): + records = [r for r in caplog.records if getattr(r, "type", None) == "db_time"] + assert records + for r in records: + text = r.getMessage() + repr(r.__dict__) + for value in (SECRET, *FILTERS.values()): + assert value not in text + assert r.has_search is True + assert r.search_len == len(SECRET) + assert r.has_smeta_type and r.has_smeta_direction and r.has_org_name + + +@pytest.mark.asyncio +async def test_page_log_has_no_search_values(caplog): + caplog.set_level(logging.INFO) + await summary_api.get_summary2( + sheet="GO", year=2031, response=Response(), search=SECRET, db=None, + current_user=SimpleNamespace(id=1), view="compact", **FILTERS, + ) + _assert_no_values(caplog) + + +@pytest.mark.asyncio +async def test_facets_log_has_no_search_values(caplog): + caplog.set_level(logging.INFO) + await summary_api.get_summary2_facets( + sheet="GO", year=2031, response=Response(), search=SECRET, db=None, + current_user=SimpleNamespace(id=1), **FILTERS, + ) + _assert_no_values(caplog) -- 2.47.2 From c8e957b13d21fa292008a153405beebc0a873d0a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 14:07:01 +0400 Subject: [PATCH 17/17] =?UTF-8?q?perf(svod2):=20=D1=83=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=202=20=D0=B1=D0=B5=D0=B7=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск в v3.svod2_lines строил jsonb header || contract_summary || contract_detail на каждую строку, разворачивал его в jsonb_each и прогонял каждое значение через svod2_casefold, а числа через svod2_js_number_text. Теперь: - разбор поисковой строки (числа возможны, нет U+0001, нужные пары таблицы регистра) делается один раз в CTE q; раньше при подстановке SQL-функции выражение нормализации p_search вычислялось на каждое значение каждой строки; - значения берутся напрямую из колонок, без jsonb; склеиваются и проверяются одним strpos через разделитель U+0001 (со строкой поиска, где есть U+0001, значения проверяются по одному); - регистр данных приводится только по парам таблицы, чей результат есть в строке поиска; - числа в текст JS переводятся, только если все символы строки возможны в записи числа, и только если по остальным значениям совпадения нет; нули без обращения к plpgsql; - v_svod2_go и v_svod2_rf с SET jit = off (JIT плана добавлял сотни миллисекунд). Тесты: поиск svod2_lines равен прежнему поиску по jsonb (эталонный SQL в тесте) на обоих листах для 55 строк, включая числа, экспоненту, NaN и Infinity, стык значений, U+0001, ненормализованные строки; состав колонок contract_* закреплён тестом. Co-Authored-By: Claude Opus 5.5 --- .../versions/sql/0053_svod2_server_paging.sql | 163 ++++++++++++------ api/tests/integration/test_svod2_sql.py | 134 ++++++++++++++ 2 files changed, 248 insertions(+), 49 deletions(-) diff --git a/api/alembic/versions/sql/0053_svod2_server_paging.sql b/api/alembic/versions/sql/0053_svod2_server_paging.sql index d2162c2..8bf3720 100644 --- a/api/alembic/versions/sql/0053_svod2_server_paging.sql +++ b/api/alembic/versions/sql/0053_svod2_server_paging.sql @@ -1,15 +1,40 @@ -- AURORA-1843, миграция 0053, часть 1: нормализаторы поиска как в JS и компактная проекция строки свода 2. -- Регистр без зависимости от локали: явная таблица translate (ASCII, кириллица, Ґ, латиница-1 без U+00D7). +-- Таблица в двух IMMUTABLE-функциях (свернутся в константы при планировании): svod2_lines берёт из неё +-- только пары, нужные для поиска. +CREATE OR REPLACE FUNCTION v3.svod2_casefold_from() +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT E'\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u0419\u041A\u041B\u041C\u041D\u041E\u041F\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042A\u042B\u042C\u042D\u042E\u042F\u0400\u0401\u0402\u0403\u0404\u0405\u0406\u0407\u0408\u0409\u040A\u040B\u040C\u040D\u040E\u040F\u0490\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE' +$function$; + +CREATE OR REPLACE FUNCTION v3.svod2_casefold_to() +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT E'\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u0439\u043A\u043B\u043C\u043D\u043E\u043F\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044A\u044B\u044C\u044D\u044E\u044F\u0450\u0451\u0452\u0453\u0454\u0455\u0456\u0457\u0458\u0459\u045A\u045B\u045C\u045D\u045E\u045F\u0491\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE' +$function$; + CREATE OR REPLACE FUNCTION v3.svod2_casefold(x text) RETURNS text LANGUAGE sql IMMUTABLE AS $function$ - SELECT translate( - x, - E'\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u0419\u041A\u041B\u041C\u041D\u041E\u041F\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042A\u042B\u042C\u042D\u042E\u042F\u0400\u0401\u0402\u0403\u0404\u0405\u0406\u0407\u0408\u0409\u040A\u040B\u040C\u040D\u040E\u040F\u0490\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE', - E'\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u0439\u043A\u043B\u043C\u043D\u043E\u043F\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044A\u044B\u044C\u044D\u044E\u044F\u0450\u0451\u0452\u0453\u0454\u0455\u0456\u0457\u0458\u0459\u045A\u045B\u045C\u045D\u045E\u045F\u0491\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE' - ) + SELECT translate(x, v3.svod2_casefold_from(), v3.svod2_casefold_to()) +$function$; + +-- Регистр данных для поиска по уже нормализованной строке p_search: достаточно заменить символы, чьё +-- приведение даёт символ из p_search (остальные не могут совпасть ни с одним символом p_search), а при +-- p_full (p_search не нормализована) берётся полная таблица. Результат strpos(.., p_search) тот же, +-- что у svod2_casefold, но без прохода по всей таблице на каждый символ значения. +CREATE OR REPLACE FUNCTION v3.svod2_casefold_for_search(x text, p_full boolean, p_from text, p_to text) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN p_full THEN v3.svod2_casefold(x) + WHEN p_from IS NULL THEN x + ELSE translate(x, p_from, p_to) END $function$; -- Обрезка как String.prototype.trim: ECMAScript WhiteSpace и LineTerminator. @@ -240,9 +265,39 @@ $function$; DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar); DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar); +-- Число как в поиске по экрану: ноль без обращения к plpgsql (большинство сумм - нули), иначе запись JS. +CREATE OR REPLACE FUNCTION v3.svod2_num_text(n numeric) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN n IS NULL THEN NULL + WHEN n = 0 THEN '0' + ELSE v3.svod2_js_number_text(n::float8) END +$function$; + +-- Есть ли в значениях подстрока p_s (нормализованная). Одна склейка и один strpos: искомая строка без +-- U+0001 не может перекрыть границу значений, ложного совпадения на стыке нет (U+0001 внутри самих +-- значений безвреден). Со строкой, где есть U+0001, каждое значение проверяется отдельно, как matchesSearch. +CREATE OR REPLACE FUNCTION v3.svod2_vals_match( + vals text[], p_s text, p_sep_ok boolean, p_full boolean, p_from text, p_to text) +RETURNS boolean +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN p_sep_ok + THEN strpos(v3.svod2_casefold_for_search(array_to_string(vals, E'\u0001'), p_full, p_from, p_to), p_s) > 0 + ELSE EXISTS (SELECT 1 FROM unnest(vals) AS u(x) + WHERE strpos(v3.svod2_casefold_for_search(u.x, p_full, p_from, p_to), p_s) > 0) + END +$function$; + -- Общий отбор строк свода 2 для страниц и вариантов фильтров. Условия отбора как в 0049, -- форма выбирается по p_sheet; p_search (уже нормализованный) отсекает строки без совпадения --- по полям header, contract_summary и contract_detail, как matchesSearch экрана. +-- по значениям header, contract_summary и contract_detail, как matchesSearch экрана. +-- Значения берутся напрямую из колонок, без jsonb. Набор повторяет ключи прежнего +-- header || contract_summary || contract_detail (при совпадении ключей побеждает последний): +-- у contract_summary остаются поля, которых нет у contract_detail, а counterparty, reference, +-- deadline и comment берутся только из contract_detail (отсутствие строки contract_detail +-- затирает их у contract_summary значением null). Состав колонок сверяет тест. CREATE OR REPLACE FUNCTION v3.svod2_lines( p_sheet varchar, p_year integer, @@ -256,10 +311,22 @@ RETURNS TABLE( ) LANGUAGE sql STABLE AS $function$ -WITH empty_sections AS MATERIALIZED ( - SELECT - to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, - to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail +WITH q AS MATERIALIZED ( + -- Разбор поисковой строки один раз на запрос (при подстановке SQL-функции в вызывающий запрос + -- выражение p_search иначе вычислялось бы на каждое значение). + -- num_ok: все символы строки возможны в записи числа JS (цифры, . - + e, Infinity, NaN); + -- иначе ни одно число не может совпасть, и числа в текст не переводятся; + -- sep_ok: в строке нет U+0001, разделителя при склейке значений строки свода; + -- fold_*: пары таблицы регистра, чей результат есть в строке (см. svod2_casefold_for_search). + SELECT p_search AS s, + p_search ~ '^[0-9.+eEinftyaINFTYA-]*$' AS num_ok, + strpos(p_search, E'\u0001') = 0 AS sep_ok, + v3.svod2_casefold(p_search) <> p_search AS fold_full, + m.f AS fold_from, m.t AS fold_to + FROM (SELECT string_agg(c.f, '' ORDER BY c.i) AS f, string_agg(c.t, '' ORDER BY c.i) AS t + FROM unnest(regexp_split_to_array(v3.svod2_casefold_from(), ''), + regexp_split_to_array(v3.svod2_casefold_to(), '')) WITH ORDINALITY AS c(f, t, i) + WHERE strpos(p_search, c.t) > 0) m ), eligible_lines AS ( -- svod2_lines eligible: begin SELECT DISTINCT @@ -380,56 +447,52 @@ WITH empty_sections AS MATERIALIZED ( SELECT el.line_id, el.form_type_code, el.form_id, el.org_unit_id, el.org_name, el.section_code, el.item_id, el.smeta_type, el.smeta_direction FROM eligible_lines el -WHERE p_search IS NULL OR EXISTS ( +CROSS JOIN q +WHERE q.s IS NULL OR EXISTS ( SELECT 1 FROM v3.budget_line bl JOIN v3.expense_item ei ON ei.id = bl.expense_item_id LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id LEFT JOIN v3.ckk ck ON ck.line_id = bl.id - CROSS JOIN empty_sections e CROSS JOIN LATERAL ( SELECT - jsonb_build_object( - 'section_code', ei.section_code, 'item_id', ei.item_id, - 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), - 'expense_item_id', ei.id, 'justification', bl.justification, - 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, - 'project_id', bl.project_id, 'direction', bl.direction, - 'smeta_direction', CASE bl.direction - WHEN 'Support' THEN 'Поддержка' - WHEN 'Development' THEN 'Развитие' - ELSE NULL - END, - 'source_form', el.form_type_code, 'source_id', el.form_id, - 'source_org_unit_id', el.org_unit_id, 'org_name', el.org_name, - 'smeta_type', el.smeta_type) AS header_j, - COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary) AS cs_j, - CASE WHEN p_sheet = 'RF' - THEN COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) || jsonb_build_object( - 'expenses_q1', COALESCE(cd.expenses_q1, ck.expenses_q1), - 'expenses_q2', COALESCE(cd.expenses_q2, ck.expenses_q2), - 'expenses_q3', COALESCE(cd.expenses_q3, ck.expenses_q3), - 'expenses_q4', COALESCE(cd.expenses_q4, ck.expenses_q4)) - ELSE COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) - END AS cd_j + ARRAY[ei.section_code, ei.item_id, ei.num_group_id, COALESCE(bl.name, ei.name), + bl.justification, bl.internal_order, bl.direction, el.smeta_direction, + el.form_type_code, el.org_name, el.smeta_type]::text[] AS strs, + CASE WHEN q.num_ok + THEN ARRAY[ei.id::text, bl.vsp_id::text, bl.project_id::text, + el.form_id::text, el.org_unit_id::text] + END AS ints, + CASE WHEN has_contract.v + THEN ARRAY[cs.centralized_flag, cd.counterparty, cd.reference, cd.addenda, cd.subject, + cd.currency, cd.rf_schedule, cd.vat_rate, cd.deadline, cd.payment_scheme, + cd.act, cd.comment, to_jsonb(cd.contract_date) #>> '{}']::text[] + END AS rest_s, + CASE WHEN has_contract.v AND q.num_ok + THEN ARRAY[cs.total_amount, cs.future_payments_y1, cs.future_payments_y2, cs.other_ssp_amount, + cd.ceiling_amount, cd.exchange_rate, cd.amount_foreign, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q1, ck.expenses_q1) ELSE cd.expenses_q1 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q2, ck.expenses_q2) ELSE cd.expenses_q2 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q3, ck.expenses_q3) ELSE cd.expenses_q3 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q4, ck.expenses_q4) ELSE cd.expenses_q4 END]::numeric[] + END AS rest_n + FROM (SELECT cs.line_id IS NOT NULL OR cd.line_id IS NOT NULL + OR (p_sheet = 'RF' AND ck.line_id IS NOT NULL) AS v) has_contract ) s - CROSS JOIN LATERAL jsonb_each(s.header_j || s.cs_j || s.cd_j) kv WHERE bl.id = el.line_id - AND strpos( - v3.svod2_casefold( - CASE jsonb_typeof(kv.value) - WHEN 'string' THEN kv.value #>> '{}' - WHEN 'number' THEN v3.svod2_js_number_text((kv.value #>> '{}')::float8) - WHEN 'boolean' THEN kv.value #>> '{}' - WHEN 'null' THEN '' - ELSE kv.value::text - END), - p_search) > 0 + AND CASE + WHEN v3.svod2_vals_match(s.strs || s.ints || s.rest_s, q.s, q.sep_ok, + q.fold_full, q.fold_from, q.fold_to) THEN true + -- числа переводятся в текст только если по остальным значениям совпадения нет + WHEN s.rest_n IS NOT NULL THEN v3.svod2_vals_match( + ARRAY(SELECT v3.svod2_num_text(n) FROM unnest(s.rest_n) AS u(n)), + q.s, q.sep_ok, q.fold_full, q.fold_from, q.fold_to) + ELSE false + END ) $function$; - -- Страница свода 2 для ГО: поиск и три фильтра на сервере, total_count - число строк до LIMIT. CREATE OR REPLACE FUNCTION v3.v_svod2_go( p_year integer, @@ -449,7 +512,8 @@ RETURNS TABLE( org_name varchar, smeta_type varchar, source_line_id integer, sort_order bigint, data jsonb, total_count bigint ) -LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +-- jit off: оценка стоимости большого плана включает JIT-компиляцию на сотни миллисекунд, а выигрыша нет. +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' SET jit TO off AS $function$ WITH empty_sections AS MATERIALIZED ( SELECT @@ -667,7 +731,8 @@ RETURNS TABLE( org_name varchar, smeta_type varchar, source_line_id integer, sort_order bigint, data jsonb, total_count bigint ) -LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +-- jit off: оценка стоимости большого плана включает JIT-компиляцию на сотни миллисекунд, а выигрыша нет. +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' SET jit TO off AS $function$ WITH empty_sections AS MATERIALIZED ( SELECT diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index 0459868..6f6b9cf 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -132,3 +132,137 @@ def test_facets_search_subplan_is_not_correlated(svod2_sql): outputs = [l for l in plan.splitlines() if "Output:" in l and "SubPlan" in l] assert any("hashed SubPlan" in l for l in outputs), plan assert not any(re.search(r"(?> '{}' + WHEN 'number' THEN v3.svod2_js_number_text((kv.value #>> '{}')::float8) + WHEN 'boolean' THEN kv.value #>> '{}' + WHEN 'null' THEN '' + ELSE kv.value::text + END), + :s) > 0 +) +""" + +# Данные для границ ускорения: соседние значения строки свода (name и justification идут подряд при склейке), +# U+0001 внутри значения, числа с экспонентой и отрицательные, ckk без contract_detail, дата. +SEARCH_EXTRA_DATA = [ + "UPDATE v3.budget_line SET name = 'ab', justification = 'cd' WHERE id = 910001", + "UPDATE v3.budget_line SET internal_order = E'x\\u0001y' WHERE id = 910004", + "INSERT INTO v3.contract_detail (line_id, counterparty, reference, ceiling_amount, expenses_q1, expenses_q2, " + "exchange_rate, amount_foreign, contract_date, act) VALUES " + "(910007, 'ЗАО Эй', 'Ref-А', 15000000000000000000000000, 12.5, 0, 0.0000001, -0.0000001234, '2031-03-04', 'Infinity')", + "UPDATE v3.contract_detail SET exchange_rate = 'NaN', amount_foreign = '-Infinity' WHERE line_id = 910002", + "INSERT INTO v3.ckk (line_id, ceiling_amount, expenses_q1, expenses_q2) VALUES (910009, 1, 555.5, 6)", +] + +# Нормализованные (как приходят от v_svod2_*) и ненормализованные строки; по ветвям ускорения: +# только символы числа (числа переводятся в текст) и нет (не переводятся), есть/нет пар таблицы регистра, +# U+0001 в строке, стык значений, пустая строка. +SEARCH_EQUIV = [ + "", "аренд", "ё", "ёлк", "зао", "эй", "ref-а", "ооо", "д-", "zzzz", "а", + "1", "0", "-", ".", "e", "+", "e+25", "1.5e+25", "1e-7", "-1.234e-7", "-1", "555.5", "777.25", "12.5", "1234.5", + "infinity", "nan", "a", "nt", "inf", "2031-03-04", "03-04", "12", + "abcd", "ab", "cd", "bc", "abc", "ab\u0001cd", "x\u0001y", "x", "y", "\u0001", "y\u0001", "\u0001x", "аренд\u0001", + "АРЕНД", "Ё", "E", "Infinity", "ЭЙ", "Ab", "REF", +] + + +def test_search_columns_are_those_known_to_svod2_lines(svod2_sql): + # svod2_lines перечисляет значения для поиска явно (по колонкам, без jsonb). Новая колонка в + # contract_summary/contract_detail не попадёт в поиск, пока её не добавят в svod2_lines: тест просит это сделать. + # Из contract_summary в поиск идут только колонки, которых нет в contract_detail: прежний + # header || contract_summary || contract_detail затирал одноимённые. + def columns(table: str) -> set[str]: + return {r[0] for r in svod2_sql( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = 'v3' AND table_name = :t", {"t": table})} + + assert columns("contract_summary") == { + "line_id", "total_amount", "counterparty", "reference", "deadline", "comment", + "future_payments_y1", "future_payments_y2", "other_ssp_amount", "centralized_flag"} + assert columns("contract_detail") == { + "line_id", "counterparty", "reference", "addenda", "subject", "currency", "ceiling_amount", + "expenses_q1", "expenses_q2", "expenses_q3", "expenses_q4", "rf_schedule", "vat_rate", + "exchange_rate", "amount_foreign", "deadline", "payment_scheme", "act", "comment", "contract_date"} + # ключи header не пересекаются с колонками contract_* + header_keys = { + "section_code", "item_id", "num_group", "name", "expense_item_id", "justification", + "internal_order", "vsp_id", "project_id", "direction", "smeta_direction", "source_form", + "source_id", "source_org_unit_id", "org_name", "smeta_type"} + assert not header_keys & (columns("contract_summary") | columns("contract_detail")) + + +@pytest.mark.parametrize("sheet", ["GO", "RF"]) +@pytest.mark.parametrize("search", SEARCH_EQUIV, ids=[repr(s) for s in SEARCH_EQUIV]) +def test_svod2_lines_search_equals_reference(svod2_sql, svod2_data, sheet, search): + for statement in SEARCH_EXTRA_DATA: + svod2_sql(statement) + got = svod2_sql( + "SELECT line_id FROM v3.svod2_lines(:sh, 2031, NULL, :s)", {"sh": sheet, "s": search}) + expected = svod2_sql(REFERENCE_SEARCH_SQL, {"sh": sheet, "s": search}) + assert sorted(r[0] for r in got) == sorted(r[0] for r in expected) + + +def test_svod2_lines_search_extra_data_is_searchable(svod2_sql, svod2_data): + # Страховка от пустых сравнений: на этих данных поиск что-то находит и что-то отсекает. + for statement in SEARCH_EXTRA_DATA: + svod2_sql(statement) + + def found(sheet: str, search: str) -> int: + return len(svod2_sql( + "SELECT line_id FROM v3.svod2_lines(:sh, 2031, NULL, :s)", {"sh": sheet, "s": search})) + + assert found("GO", "ab") == 1 and found("GO", "cd") == 1 + assert found("GO", "abcd") == 0 and found("GO", "ab\u0001cd") == 0 # стык значений + assert found("GO", "x\u0001y") == 1 # U+0001 внутри значения + assert found("RF", "e+25") == 1 and found("RF", "-1.234e-7") == 1 # число как в JS + assert found("RF", "555.5") == 1 # ckk без contract_detail + assert found("RF", "zzzz") == 0 -- 2.47.2