AURORA-1907: признак источника строки АХР и раздельные суммы лимита/сверх лимита #271

Open
aep wants to merge 5 commits from feat/270-aurora-1907-source-sheet into test
Owner

Closes #270.

Что сделано

Бэкенд (миграция 0053_form2_ahr_source_sheet.sql):

  • v3.budget_line.source_sheet varchar NULL CHECK (... IN ('AHR_LIMIT','AHR_RENT','AHR_UTILITY','AHR_SECURITY')) + ORM-поле в api/src/db/models/budget_line.py.
  • Backfill существующих строк: сателлиты — из v3.form2_ahr_satellite_line_source; лимитные — строка с минимальным id в группе (форма, статья, имя LEAF) помечается AHR_LIMIT, остальные совпавшие по имени становятся ручными (список — RAISE NOTICE).
  • _ensure_form2_ahr_detail_sync_line — ставит source_sheet при создании/привязке строки сателлита.
  • _ensure_form2_ahr_limit_sync_line — ищет строку среди source_sheet='AHR_LIMIT' (а не по имени) — тот же класс бага, что чинил #265/AURORA-1909 для сателлитов, теперь для лимитов.
  • is_form2_ahr_satellite_line, del_budget_line (guard на удаление), get_form2_ahr_satellite_delta — переведены на признак источника вместо сравнения имён.
  • Новая v3._form2_expense_item_has_limit(expense_item_id) — правило «у статьи есть лимит», используется и в header.is_over_limit, и в агрегатах.
  • v_form2_sheet_jsonb: header.source (AHR_LIMIT/AHR_RENT/AHR_UTILITY/AHR_SECURITY/MANUAL), header.is_over_limit; plan.limit_year/plan.over_limit_year только на листе AHR, свёрнуты по дереву статей через mv_expense_item_tree.desc_ids. plan.year не меняется — сумма всех строк, включая ручные.

Фронт:

  • AHR.js — колонки «В пределах лимита, год» / «Сверх лимита, год» после «План, год», нередактируемые.
  • Cell.jsx — чип «сверх лимита» в ячейке наименования строки по header.is_over_limit, без заливки строки цветом.
  • export_mappings.py — те же два поля в выгрузке АХР (в общем FORM1_DEPTH_COLUMNS, см. рулинг R-3).

Дефолты из тикета (применены как есть)

  • «Сверх лимита» считается только для статей из листа лимитов (не для аренды/коммуналки/охраны).
  • Разбивка показывается только на листе АХР и в его выгрузке (не в Смете/Сводах).
  • Без разбивки по кварталам — только за год.

Рулинги, принятые самостоятельно (без обсуждения)

  • R-1: _ensure_form2_ahr_limit_sync_line ищет строку по (budget_form_id, expense_item_id, source_sheet='AHR_LIMIT', name) — имя оставлено в поиске, т.к. у одной статьи может быть несколько LEAF лимитов (пример — «Мобильная связь», 7 LEAF с разными именами на одном expense_item_id); без ��мени строки лимитов были бы неотличимы друг от друга. При совпадении строки теперь дополнительно обновляется name/updated_at (раньше не обновлялось, т.к. совпадение и так шло по имени).
  • R-2: get_form2_ahr_satellite_delta — доп. фильтр source_sheet='AHR_LIMIT' добавлен только в ветку лимитов (защита от того самого бага с совпадением имени). Ветка сателлитов (аренда/коммуналка/охрана) не трогалась — там сопоставление и так однозначно идёт по source_id через form2_ahr_satellite_line_source.
  • R-3: export_mappings.py — колонки limit_year/over_limit_year добавлены в общий FORM1_DEPTH_COLUMNS (используется для OPER/AHR/CAP и для FORM_1, и для FORM_2 — отдельного columns_dict для FORM_2/AHR в экспорте не существует). Для сеток, где этих ключей в JSON нет (SMETA/CAP/OPER/FORM_1), normalized.get(key) отдаёт None → ячейка пустая, падения экспорта нет. Ticket явно разрешал не углубляться в интеграцию с AURORA-1902.
  • R-4: backfill лимитных строк ограничен bf.form_type_code='FORM_2' — тот же expense_item_id используется и в FORM_1/FORM_3/FORM_4 АХР (где лимитной синхронизации нет вовсе), совпадение имени там было бы случайным.
  • R-5: is_form2_ahr_satellite_line оборачивает source_sheet IS NOT NULL в COALESCE(..., false) — чтобы сохранить поведение «строка/форма не найдена → false», как было с EXISTS в старой версии.
  • R-6: тестовые статьи каталога подбирались эмпирически через psql на тестовой БД, а не по тексту миграций 0003/0013 — реальные id каталога (после more поздних re-seed миграций) отличаются от текста исходных INSERT (например, «Мобильная связь» — expense_item_id=150, не 129, как в тексте 0003_initial_data.sql).
  • R-7: регрессионный тест на совпадение имени (п.4 тикета) использует ручную строку с именем "Директор" (без дефиса) — ровно то значение, которое _ensure_form2_ahr_limit_sync_line до фикса искало после regexp_replace LEAF "- Директор".

Тесты

api/tests/integration/test_form2_ahr_satellite_integration.py — 6 новых тестов (4 через API, 2 прямым SQL на backfill-логику). Полный прогон pytest --init_db на одноразовом Postgres 16 (Docker, порт 55433, не perf-pg): 237 passed, 3 failed (ровно те же 3 падения, что и на родительском коммите #266 без этой ветки — см. ниже), 4 skipped. Фронтовый unit-тест на чип не писался — логика тривиальна (один isOverLimit булев флаг), фокус был на backend TDD, как и разрешал тикет.

Известные (не из этой серии) падения, не изменились относительно ab765f0:

  • test_db_functions_integration.py::test_backend_calls_form3_add_project_and_add_line_functions
  • test_export_service_query_params.py::test_export_full_form_ignores_non_applicable_params_for_other_sheets
  • test_export_service_query_params.py::test_export_form_payload_forwards_ignore_flag

🤖 Generated with Claude Code

Closes #270. ## Что сделано Бэкенд (миграция `0053_form2_ahr_source_sheet.sql`): - `v3.budget_line.source_sheet varchar NULL CHECK (... IN ('AHR_LIMIT','AHR_RENT','AHR_UTILITY','AHR_SECURITY'))` + ORM-поле в `api/src/db/models/budget_line.py`. - Backfill существующих строк: сателлиты — из `v3.form2_ahr_satellite_line_source`; лимитные — строка с минимальным `id` в группе (форма, статья, имя LEAF) помечается `AHR_LIMIT`, остальные совпавшие по имени становятся ручными (список — `RAISE NOTICE`). - `_ensure_form2_ahr_detail_sync_line` — ставит `source_sheet` при создании/привязке строки сателлита. - `_ensure_form2_ahr_limit_sync_line` — ищет строку среди `source_sheet='AHR_LIMIT'` (а не по имени) — тот же класс бага, что чинил #265/AURORA-1909 для сателлитов, теперь для лимитов. - `is_form2_ahr_satellite_line`, `del_budget_line` (guard на удаление), `get_form2_ahr_satellite_delta` — переведены на признак источника вместо сравнения имён. - Новая `v3._form2_expense_item_has_limit(expense_item_id)` — правило «у статьи есть лимит», используется и в `header.is_over_limit`, и в агрегатах. - `v_form2_sheet_jsonb`: `header.source` (`AHR_LIMIT`/`AHR_RENT`/`AHR_UTILITY`/`AHR_SECURITY`/`MANUAL`), `header.is_over_limit`; `plan.limit_year`/`plan.over_limit_year` только на листе `AHR`, свёрнуты по дереву статей через `mv_expense_item_tree.desc_ids`. `plan.year` не меняется — сумма всех строк, включая ручные. Фронт: - `AHR.js` — колонки «В пределах лимита, год» / «Сверх лимита, год» после «План, год», нередактируемые. - `Cell.jsx` — чип «сверх лимита» в ячейке наименования строки по `header.is_over_limit`, без заливки строки цветом. - `export_mappings.py` — те же два поля в выгрузке АХР (в общем `FORM1_DEPTH_COLUMNS`, см. рулинг R-3). ## Дефолты из тикета (применены как есть) - «Сверх лимита» считается только для статей из листа лимитов (не для аренды/коммуналки/охраны). - Разбивка показывается только на листе АХР и в его выгрузке (не в Смете/Сводах). - Без разбивки по кварталам — только за год. ## Рулинги, принятые самостоятельно (без обсуждения) - **R-1**: `_ensure_form2_ahr_limit_sync_line` ищет строку по `(budget_form_id, expense_item_id, source_sheet='AHR_LIMIT', name)` — имя оставлено в поиске, т.к. у одной статьи может быть несколько LEAF лимитов (пример — «Мобильная связь», 7 LEAF с разными именами на одном `expense_item_id`); без ��мени строки лимитов были бы неотличимы друг от друга. При совпадении строки теперь дополнительно обновляется `name`/`updated_at` (раньше не обновлялось, т.к. совпадение и так шло по имени). - **R-2**: `get_form2_ahr_satellite_delta` — доп. фильтр `source_sheet='AHR_LIMIT'` добавлен только в ветку лимитов (защита от того самого бага с совпадением имени). Ветка сателлитов (аренда/коммуналка/охрана) не трогалась — там сопоставление и так однозначно идёт по `source_id` через `form2_ahr_satellite_line_source`. - **R-3**: `export_mappings.py` — колонки `limit_year`/`over_limit_year` добавлены в общий `FORM1_DEPTH_COLUMNS` (используется для OPER/AHR/CAP и для FORM_1, и для FORM_2 — отдельного columns_dict для FORM_2/AHR в экспорте не существует). Для сеток, где этих ключей в JSON нет (SMETA/CAP/OPER/FORM_1), `normalized.get(key)` отдаёт `None` → ячейка пустая, падения экспорта нет. Ticket явно разрешал не углубляться в интеграцию с AURORA-1902. - **R-4**: backfill лимитных строк ограничен `bf.form_type_code='FORM_2'` — тот же `expense_item_id` используется и в FORM_1/FORM_3/FORM_4 АХР (где лимитной синхронизации нет вовсе), совпадение имени там было бы случайным. - **R-5**: `is_form2_ahr_satellite_line` оборачивает `source_sheet IS NOT NULL` в `COALESCE(..., false)` — чтобы сохранить поведение «строка/форма не найдена → false», как было с EXISTS в старой версии. - **R-6**: тестовые статьи каталога подбирались эмпирически через psql на тестовой БД, а не по тексту миграций 0003/0013 — реальные id каталога (после more поздних re-seed миграций) отличаются от текста исходных INSERT (например, «Мобильная связь» — `expense_item_id=150`, не 129, как в тексте 0003_initial_data.sql). - **R-7**: регрессионный тест на совпадение имени (п.4 тикета) использует ручную строку с именем `"Директор"` (без дефиса) — ровно то значение, которое `_ensure_form2_ahr_limit_sync_line` до фикса искало после `regexp_replace` LEAF `"- Директор"`. ## Тесты `api/tests/integration/test_form2_ahr_satellite_integration.py` — 6 новых тестов (4 через API, 2 прямым SQL на backfill-логику). Полный прогон `pytest --init_db` на одноразовом Postgres 16 (Docker, порт 55433, не `perf-pg`): 237 passed, 3 failed (ровно те же 3 падения, что и на родительском коммите #266 без этой ветки — см. ниже), 4 skipped. Фронтовый unit-тест на чип не писался — логика тривиальна (один `isOverLimit` булев флаг), фокус был на backend TDD, как и разрешал тикет. Известные (не из этой серии) падения, не изменились относительно `ab765f0`: - `test_db_functions_integration.py::test_backend_calls_form3_add_project_and_add_line_functions` - `test_export_service_query_params.py::test_export_full_form_ignores_non_applicable_params_for_other_sheets` - `test_export_service_query_params.py::test_export_form_payload_forwards_ignore_flag` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
aep added 5 commits 2026-10-31 16:55:45 +03:00
Добавлен столбец «СПОД» между «Декабрь» и «Итог IV кв.» на листах
AHR_RENT/AHR_UTILITY/AHR_SECURITY формы 2, с переносом значения в
budget_line_quarter.actual_spod синхронизируемой строки АХР (Support)
и, через уже существующую логику, в итог 4 кв. и года АХР/Смет/сводов.

- rent_detail/utility_detail/security_detail: колонка actual_spod
  (ALTER TABLE + ORM).
- v3.v_form2_view: fact_q4.spod и fact_q4.total с учётом spod в ветках
  AHR_RENT/AHR_UTILITY/AHR_SECURITY.
- v3._apply_form_cell: fact_q4.spod разрешён как обычное поле месяца.
- v3._upsert_form2_ahr_detail_sync_values: DROP+CREATE с новым
  параметром p_actual_spod (DROP обязателен из-за неоднозначности
  перегрузки с DEFAULT).
- v3.sync_form2_ahr_satellite_lines/line: actual_spod передаётся из
  rd/ud/sd в апсерт.
- v3.v_svod2_go: строка аренды также считается заполненной по
  actual_spod.
- Data-миграция: 'fact_q4.spod' добавлен в column_keys этапов, где уже
  есть 'fact_q4.dec' (phase_template/form_phase), идемпотентно.
- Тело CREATE OR REPLACE для v_form2_view/sync_form2_ahr_satellite_*
  собрано вручную по миграциям 0033/0034 + regexp-патчам из 0037
  (pg_get_functiondef на живой БД недоступен в этой среде — см.
  Rulings в PR).

Тесты: node --test на новых accessorKey в конфигах фронта + unit-тест
на 0049/0050 (v_svod2_go теперь засчитывает actual_spod для аренды).
Интеграционные тесты добавлены, но не выполнены — нет доступа к
Postgres в этой рабочей среде (см. Rulings в PR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Прогнал api/tests/integration/test_form2_ahr_satellite_spod_integration.py
против живого Postgres (perf/perf/perf на localhost:55432, БД dfip_1906,
alembic upgrade head + фикстура) — нашёл и поправил два бага самого теста
(фичу/миграцию не трогал):

- _find_row не учитывал служебную META-строку (editable/role/user_id без
  id/header), которую v3.v_form_view добавляет первой в любой лист —
  KeyError: 'id' на первой же выборке. Теперь ищет только среди INPUT-строк.
- test_satellite_spod_phase_gates_non_admin_user делал один тест из
  "запрет без fact_q4.spod → добавили ключ → разрешено": неадминский
  403/400 от isp1 внутри теста вызывает db_ref.rollback() в
  tests/integration/conftest.py (per-request откат на ошибке), который
  стирает все несохранённые изменения этого же теста — включая только
  что созданные строку сателлита и form_phase. Разбил на два независимых
  теста (test_satellite_spod_rejected_before_phase_includes_spod_column /
  test_satellite_spod_allowed_once_phase_includes_spod_column), в каждом
  ровно один запрос, которому разрешено провалиться.

pytest tests/integration — 157 passed, 1 failed (pre-existing,
не связан: test_backend_calls_form3_add_project_and_add_line_functions,
FORM_3/add_project, 400 на /api/v1/projects/.../report/.../line), 4 skipped.
Новый файл — 15/15.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v3.add_budget_line (ветка сателлитов AHR_RENT/AHR_UTILITY/AHR_SECURITY)
искала любую существующую строку статьи без ORDER BY и без исключения
ручных/уже привязанных строк, и переиспользовала её как заготовку для
новой записи. Если у статьи ещё не было строк в
v3.form2_ahr_satellite_line_source (типично — первая запись при наличии
ручной строки той же статьи), sync переименовывал и перезаписывал план/факт
чужой ручной строки, блокируя её для правки.

Теперь для каждой новой записи сателлита всегда создаётся отдельная
пустая строка-заготовка — sync забирает её как строку именно этой записи.
Заодно удалён мёртвый код (_ensure_form2_ahr_sync_line,
_upsert_form2_ahr_sync_values), не имевший вызывающих мест.

AURORA-1907 (лимитная часть) и AURORA-1887 (статьи 314/24/779 в коде) —
отдельные задачи, не затронуты.

Closes #265.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AURORA-1897: лист «АХР лимиты» формы 2 дублировал годовой норматив ДМС/оргтехники
в каждом квартале вместо деления на 4, считал сумму от лимита с НДС (смета
ведётся без НДС) и переносил рубли в план АХР, где всё остальное в тыс. руб.

Миграция 0052_form2_ahr_limit_period_vat_units:
- v3.limit_template.period (YEAR/QUARTER/MONTH) + точечные правки справочника
  (id 24/25 ДМС и id 13-15 принтеры/МФУ/копиры — YEAR; id 7/8 SIM-карты —
  заполнен limit_without_vat; id 21 — unit по аналогии с бумагой).
- v3._form2_ahr_limit_amount(period, limit, qty) — рубли без НДС, без
  проверки по названию; старая 4-арг. сигнатура удалена.
- v3.v_form2_view (ветка AHR_LIMIT), v3.sync_form2_ahr_satellite_lines(_line)
  пересозданы: amount/plan считаются от limit_without_vat, план АХР делится
  на 1000, добавлены amount_year и строка «Итого» (id=0, row_type ROOT).
- v3._limit_template_path_set дописывает id=0 в path-set, чтобы «Итого»
  попадала в diff PATCH-ответа.
- Пересинхронизированы все существующие формы 2.

Ветки аренды/коммуналки/охраны и add_budget_line (AURORA-1887/1909) не тронуты.

Тесты: api/tests/integration/test_form2_ahr_limit.py (7 тестов, числа из
тикета: id24 qty10→69645/69.645, id3→2000/2.0, id7→7500/7.5, id13→6471.875/
6.471875, id17→7500/7.5, «Итого», регрессия АХР_RENT без деления).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Миграция 0053: v3.budget_line.source_sheet (NULL | AHR_LIMIT | AHR_RENT |
AHR_UTILITY | AHR_SECURITY) — явный признак источника строки вместо
сопоставления по совпадению имени с LEAF limit_template. Backfill
существующих строк: сателлиты — из v3.form2_ahr_satellite_line_source,
лимитные — минимальный id в группе (форма, статья, имя LEAF), остальные
совпавшие по имени становятся ручными (RAISE NOTICE со списком).

_ensure_form2_ahr_limit_sync_line теперь ищет строку по source_sheet='AHR_LIMIT'
(а не по имени) — тот же класс бага, что чинил #265/AURORA-1909 для сателлитов
аренды/коммуналки/охраны, теперь для лимитов. is_form2_ahr_satellite_line,
del_budget_line (guard на удаление) и get_form2_ahr_satellite_delta переведены
на признак источника. Новая v3._form2_expense_item_has_limit выносит правило
«у статьи есть лимит» в отдельную функцию.

v_form2_sheet_jsonb: header.source ('AHR_LIMIT'/'AHR_RENT'/'AHR_UTILITY'/
'AHR_SECURITY'/'MANUAL'), header.is_over_limit для ручных строк на статьях
с лимитом; plan.limit_year/over_limit_year (только на листе AHR, свёрнуты по
дереву статей через mv_expense_item_tree.desc_ids) — plan.year не меняется,
остаётся суммой всех строк.

Фронт: AHR.js — колонки «В пределах лимита, год»/«Сверх лимита, год» после
«План, год» (нередактируемые — нет случая в v3._apply_form_cell); Cell.jsx —
чип «сверх лимита» в ячейке наименования по header.is_over_limit, без заливки
строки. export_mappings.py — те же два поля в экспорт АХР.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This pull request can be merged automatically.
You are not authorized to merge this pull request.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/270-aurora-1907-source-sheet:feat/270-aurora-1907-source-sheet
git checkout feat/270-aurora-1907-source-sheet
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RSHB/DFiP_Budget_planing#271
No description provided.