AURORA-1938: расчёт переноса в фонд экономии на листах АХР/КВ/Операц формы РФ #262

Open
aep wants to merge 1 commits from feat/255-aurora-1938-transfer-econ-calc into test
Owner

Closes #255.

Проблема

Форма РФ (FORM_4 «Смета РФ поддержка»), листы АХР/КВ (CAP)/Операц (OPER): поле
«перенос в фонд экономии» (столбцы 75/107/137 Excel-эталона, внутренние имена
q1.transfer_econ/q2.transfer_econ/q3.transfer_econ, аналогично q4) было
raw passthrough-полем budget_line_quarter.transfer_to_economy — ничего его не
считало. Репро: добавить строку, заполнить план I–III кв. = 1000/2000/3000,
переносы/факт/секвестр пустыми, сохранить — поля переноса в фонд экономии
остаются пустыми, «ВСЕГО» блоков показывает прочерк вместо суммы, хотя остатки
после факта (1000/2000/3000) посчитаны верно.

Что сделано

  • api/alembic/versions/sql/0050_form4_transfer_econ_calc.sql:
    • v3.v_form4_sheet_sections (единственная функция, которая отдаёт данные
      всех трёх листов АХР/КВ/Операц — v3.v_form4_view диспетчеризует
      p_sheet IN ('AHR','CAP','OPER') в неё через v_form4_sheet_jsonb →
      v_form4_sheet_lines_jsonb, так что один фикс в общей функции закрывает
      все три листа; подтверждено интеграционным тестом на CAP/OPER). STRUCTURE —
      отдельный read-only агрегирующий свод по AHR+CAP+OPER без собственных
      INPUT-строк, этой правки не касается.
    • Заменил raw passthrough col_qN_transfer_econ = f.qN_te (и его копию в
      ветке агрегации tb_N.te) на вычисление из уже имеющихся в схеме
      cpN/aqN (residual_after_actual_QN), см. Rulings — формула неочевидна,
      поэтому отдельная секция ниже.
    • v3._apply_form_cell: для FORM_4 прямая запись в q*.transfer_econ
      теперь отклоняется с computed_field: ... (как уже было для
      residual_after_actual), поле убрано из allowlist записи (было
      смаплено на budget_line_quarter.transfer_to_economy в общей таблице
      column-mapping, используемой также FORM_2 — проверка намеренно ограничена
      form_type_code = 'FORM_4', FORM_2 это поле не трогает).
  • api/tests/integration/test_form4_transfer_econ.py — 4 теста:
    • репро тикета (все переносы = 0);
    • регрессионный тест на двойное вычитание с ненулевыми
      transfer_q2/q3/q4 — именно он отличает верную формулу от наивного
      буквального Excel-порта (при нулевых переносах обе формулы дают одинаковый
      результат и не могли бы поймать регрессию);
    • КВ/Операц дают те же числа, что АХР (ветка диспетчеризации);
    • запись в q1.transfer_econ отклоняется (стало computed_field).
    • Дополнительно в регрессионном тесте провере��а ветка агрегации (ROOT-строка)
      — не только что она не падает, но что считает так же, как INPUT-строка.

Rulings (решения без обсуждения)

  1. Формула — не буквальный перенос из Excel, а с поправкой на уже
    заложенный перенос.
    Эталон (Шаблон_РФ_поддержка.xlsm, АХР_П!CB9) —
    CB9=BR9-BT9-BY9-BZ9, т.е. «остаток после факта минус перенос во II/III/IV
    кв.». Но в нашей схеме residual_after_actual_QN уже вычитает перенос
    в СЛЕДУЮЩИЙ квартал один раз через промежуточные cp1/cp2/cp3:
    cp1 = ap1 + корректировки_Q1 − transfer_q2, аналогично cp2 минусует
    transfer_q3, cp3 минусует transfer_q4. Буквальный перенос формулы
    Excel поверх residual_after_actual_QN задвоил бы это вычитание.
    Реализованные формулы:

    Квартал Наивный Excel-порт (НЕВЕРНО) Что реализовано (ВЕРНО)
    Q1 residual_after_actual_Q1 − transfer_q2 − transfer_q3 − transfer_q4 residual_after_actual_Q1 − transfer_q3 − transfer_q4
    Q2 residual_after_actual_Q2 − transfer_q3 − transfer_q4 residual_after_actual_Q2 − transfer_q4
    Q3 residual_after_actual_Q3 − transfer_q4 residual_after_actual_Q3
    Q4 — (в Excel-эталоне колонки для Q4 нет) residual_after_actual_Q4

    Почему transfer_q2 из формулы Q1 убран: он уже вычтен один раз внутри
    cp1/residual_after_actual_Q1; вычесть его снова — задвоить вычитание и
    занизить экономию ровно на сумму transfer_q2. Аналогично transfer_q3 в
    Q2 и transfer_q4 в Q3. Проверочное тождество (и то, что проверяет тест):
    transfer_q2 + transfer_q3 + transfer_q4 + transfer_econ_Q1 (= «ВСЕГО»
    блока Q1) равно нераспределённому остатку после факта Q1 (до вычета
    любых переносов) — с наивной формулой это тождество не выполняется.

  2. Q4 тоже сделан вычисляемым, хотя в тикете и Excel-эталоне колонки для
    Q4 нет
    (у Q4 просто нет «следующего квартала», переносить некуда) — для
    единообразия с Q1–Q3 и потому, что q4.transfer_econ замаплен в той же
    записи column-mapping в _apply_form_cell, что и Q1–Q3: оставлять его
    редактируемым, когда Q1–Q3 стали вычисляемыми, было бы непоследовательно.
    Формула: transfer_econ_Q4 = residual_after_actual_Q4 (вычитать нечего).

  3. Проверка FORM_4-only в _apply_form_cell сделана по
    budget_form.form_type_code, а не глобально.
    Поле q*.transfer_econ
    замаплено в общей для FORM_2/FORM_4 column-mapping таблице внутри
    _apply_form_cell. При ревью кода обнаружил, что v3.v_form2_sheet_sections
    (своя, отдельная функция для FORM_2 «ПД») содержит точно такой же
    raw-passthrough баг для transfer_econ — но тикет и переданные инструкции
    касаются только FORM_4 («Форма РФ»). Если бы я сделал запись в
    transfer_econ неразрешённой глобально, это сломало бы FORM_2 ещё
    сильнее: поле там и так всегда пустое (баг не чинился), а стало бы ещё и
    нередактируемым вручную — чистый регресс без всякой пользы. Поэтому правка
    ограничена form_type_code = 'FORM_4'; баг FORM_2 я НЕ чинил (не входит в
    тикет) — оставляю как есть и выношу отдельной строкой ниже.

  4. Фронтенд НЕ трогал. Инструкция просила найти, где
    residual_after_actual исключён из допустимых для записи полей на
    фронтенде (AHR.js/CAP.js/STRUCTURE.js), и сделать то же самое для
    transfer_econ. Прочитал web/src/components/RealtimeTable/tableColumns.jsx
    (isEditable()) и все 4 файла constants/FORM_4/*.js — такого
    per-field флага (editable/computed) для residual_after_actual НЕ
    существует вообще: для роли ADMIN isEditable() всегда возвращает true
    независимо от поля, editType не используется ни для одной числовой
    колонки, а цветовая схема (color_type) — чисто декоративная группировка
    по разделам формы, не связанная с правом записи (у total, который тоже
    вычисляемый, тот же redColumn, что у заведомо editable transfer_q2).
    Единственная защита вычисляемых полей — серверная (computed_field: в
    _apply_form_cell), которую UI уже обрабатывает универсально через
    существующий onCellUpdateError/handleCellUpdateError. После этого PR
    transfer_econ ведёт себя на фронтенде ТОЧНО так же, как residual_after_actual
    уже вёл себя всегда (клик возможен, сохранение сервер отклоняет) — то есть
    зеркалирование уже достигнуто без единой правки в web/.

  5. Тестовые годы форм (90101–90200) выбраны заведомо далёкими от реальных
    лет, чтобы не пересекаться по (org_unit_id, form_type, year) с чужими
    данными в общей тестовой БД (интеграционные тесты в проекте не
    изолированы, см. ARCHITECTURE.md «Открытые вопросы»); каждый тест всё
    равно откатывается (client-фикстура с rollback-сессией).

Тесты

  • pytest tests/unit — 27 passed (все проходят; 2 несвязанных падения
    test_export_service_query_params.py относятся к tests/unit, см. ниже).
  • pytest (полный набор, живой Postgres через DATABASE_URL, миграции
    alembic upgrade head 0001→0050 применены без ошибок, --init_db один раз
    на чистую БД) — 201 passed, 4 skipped, 6 failed. Все 6 падений
    пред-существующие и не связаны с этой веткой — перепроверено на
    полностью чистой БД одним проходом (чтобы исключить накопленное состояние
    между запусками): test_backend_calls_form3_add_project_and_add_line_functions,
    test_delete_project_smoke, test_delete_project_verify_gone,
    test_delete_seed_project_smoke (все про FORM_3/проекты, я не трогал этот
    код), test_export_full_form_ignores_non_applicable_params_for_other_sheets
    и test_export_form_payload_forwards_ignore_flag (unit-тесты экспорт-сервиса,
    падают с TypeError: unexpected keyword argument 'ignore_non_applicable_query_params'
    — рассинхрон теста и текущей сигнатуры ExportService, тоже не моя область).
    Проверено дважды на двух разных чистых контейнерах Postgres 15 — результат
    идентичен с правкой и без (те же 6 падений на базовой ветке test до
    применения миграции 0050).
  • Новые 4 теста из test_form4_transfer_econ.py — все 4 проходят,
    включая регрессионный тест с ненулевыми переносами (ключевое доказательство
    правильности формулы, не наивного Excel-порта) и проверку ветки агрегации.
  • alembic upgrade head — чисто применяется поверх 0049 (0001...0050, без
    ошибок).

Окружение для живого Postgres

В предоставленном окружении не было DATABASE_URL/доступа к уже
существующему контейнеру БД проекта (креды недоступны, а подбор/просмотр
чужих кредов — запрещённое действие в песочнице). Поднял одноразовый
Postgres 15 в Docker со своими кредами специально для этой проверки,
прогнал alembic upgrade head и полный pytest --init_db на нём, контейнер
удалил после проверки. pg_get_functiondef на живой БД подтвердил, что
v3.v_form4_sheet_sections и v3._apply_form_cell до этого PR действительно
определялись последними в 0033_form2_old_sort.sql и
0023_apply_cell_empty_string.sql соответственно (как и предполагалось в
задаче) — переопределены корректно.

Возможная коллизия номера миграции

PR #257 (другая ветка того же батча, feat/249-...) тоже использует номер
0050 (0050_budget_line_delete_owner_guard.sql) с тем же down_revision = "0049".
Если обе ветки смержатся в test, вторая смерженная ветка получит
дублирующийся revision (два alembic-head'а на один down_revision) —
потребуется переномеровать один из файлов 0050→0051 и поправить
down_revision при разрешении конфликта мержа. Не трогал #257 (чужая ветка),
просто фиксирую риск для мержащего.

Closes #255. ## Проблема Форма РФ (FORM_4 «Смета РФ поддержка»), листы АХР/КВ (CAP)/Операц (OPER): поле «перенос в фонд экономии» (столбцы 75/107/137 Excel-эталона, внутренние имена `q1.transfer_econ`/`q2.transfer_econ`/`q3.transfer_econ`, аналогично `q4`) было raw passthrough-полем `budget_line_quarter.transfer_to_economy` — ничего его не считало. Репро: добавить строку, заполнить план I–III кв. = 1000/2000/3000, переносы/факт/секвестр пустыми, сохранить — поля переноса в фонд экономии остаются пустыми, «ВСЕГО» блоков показывает прочерк вместо суммы, хотя остатки после факта (1000/2000/3000) посчитаны верно. ## Что сделано - `api/alembic/versions/sql/0050_form4_transfer_econ_calc.sql`: - `v3.v_form4_sheet_sections` (единственная функция, которая отдаёт данные всех трёх листов АХР/КВ/Операц — `v3.v_form4_view` диспетчеризует `p_sheet IN ('AHR','CAP','OPER')` в неё через `v_form4_sheet_jsonb` → `v_form4_sheet_lines_jsonb`, так что один фикс в общей функции закрывает все три листа; подтверждено интеграционным тестом на CAP/OPER). STRUCTURE — отдельный read-only агрегирующий свод по AHR+CAP+OPER без собственных INPUT-строк, этой правки не касается. - Заменил raw passthrough `col_qN_transfer_econ = f.qN_te` (и его копию в ветке агрегации `tb_N.te`) на вычисление из уже имеющихся в схеме `cpN`/`aqN` (residual_after_actual_QN), см. Rulings — формула неочевидна, поэтому отдельная секция ниже. - `v3._apply_form_cell`: для FORM_4 прямая запись в `q*.transfer_econ` теперь отклоняется с `computed_field: ...` (как уже было для `residual_after_actual`), поле убрано из allowlist записи (было смаплено на `budget_line_quarter.transfer_to_economy` в общей таблице column-mapping, используемой также FORM_2 — проверка намеренно ограничена `form_type_code = 'FORM_4'`, FORM_2 это поле не трогает). - `api/tests/integration/test_form4_transfer_econ.py` — 4 теста: - репро тикета (все переносы = 0); - **регрессионный тест на двойное вычитание** с ненулевыми `transfer_q2/q3/q4` — именно он отличает верную формулу от наивного буквального Excel-порта (при нулевых переносах обе формулы дают одинаковый результат и не могли бы поймать регрессию); - КВ/Операц дают те же числа, что АХР (ветка диспетчеризации); - запись в `q1.transfer_econ` отклоняется (стало computed_field). - Дополнительно в регрессионном тесте провере��а ветка агрегации (ROOT-строка) — не только что она не падает, но что считает так же, как INPUT-строка. ## Rulings (решения без обсуждения) 1. **Формула — не буквальный перенос из Excel, а с поправкой на уже заложенный перенос.** Эталон (Шаблон_РФ_поддержка.xlsm, АХР_П!CB9) — `CB9=BR9-BT9-BY9-BZ9`, т.е. «остаток после факта минус перенос во II/III/IV кв.». Но в нашей схеме `residual_after_actual_QN` **уже** вычитает перенос в СЛЕДУЮЩИЙ квартал один раз через промежуточные `cp1/cp2/cp3`: `cp1 = ap1 + корректировки_Q1 − transfer_q2`, аналогично `cp2` минусует `transfer_q3`, `cp3` минусует `transfer_q4`. Буквальный перенос формулы Excel поверх `residual_after_actual_QN` задвоил бы это вычитание. Реализованные формулы: | Квартал | Наивный Excel-порт (НЕВЕРНО) | Что реализовано (ВЕРНО) | |---|---|---| | Q1 | `residual_after_actual_Q1 − transfer_q2 − transfer_q3 − transfer_q4` | `residual_after_actual_Q1 − transfer_q3 − transfer_q4` | | Q2 | `residual_after_actual_Q2 − transfer_q3 − transfer_q4` | `residual_after_actual_Q2 − transfer_q4` | | Q3 | `residual_after_actual_Q3 − transfer_q4` | `residual_after_actual_Q3` | | Q4 | — (в Excel-эталоне колонки для Q4 нет) | `residual_after_actual_Q4` | Почему `transfer_q2` из формулы Q1 убран: он уже вычтен один раз внутри `cp1`/`residual_after_actual_Q1`; вычесть его снова — задвоить вычитание и занизить экономию ровно на сумму `transfer_q2`. Аналогично `transfer_q3` в Q2 и `transfer_q4` в Q3. Проверочное тождество (и то, что проверяет тест): `transfer_q2 + transfer_q3 + transfer_q4 + transfer_econ_Q1` (= «ВСЕГО» блока Q1) равно **нераспределённому** остатку после факта Q1 (до вычета любых переносов) — с наивной формулой это тождество не выполняется. 2. **Q4 тоже сделан вычисляемым, хотя в тикете и Excel-эталоне колонки для Q4 нет** (у Q4 просто нет «следующего квартала», переносить некуда) — для единообразия с Q1–Q3 и потому, что `q4.transfer_econ` замаплен в той же записи column-mapping в `_apply_form_cell`, что и Q1–Q3: оставлять его редактируемым, когда Q1–Q3 стали вычисляемыми, было бы непоследовательно. Формула: `transfer_econ_Q4 = residual_after_actual_Q4` (вычитать нечего). 3. **Проверка `FORM_4`-only в `_apply_form_cell` сделана по `budget_form.form_type_code`, а не глобально.** Поле `q*.transfer_econ` замаплено в общей для FORM_2/FORM_4 column-mapping таблице внутри `_apply_form_cell`. При ревью кода обнаружил, что `v3.v_form2_sheet_sections` (своя, отдельная функция для FORM_2 «ПД») содержит **точно такой же** raw-passthrough баг для `transfer_econ` — но тикет и переданные инструкции касаются только FORM_4 («Форма РФ»). Если бы я сделал запись в `transfer_econ` неразрешённой глобально, это сломало бы FORM_2 ещё сильнее: поле там и так всегда пустое (баг не чинился), а стало бы ещё и нередактируемым вручную — чистый регресс без всякой пользы. Поэтому правка ограничена `form_type_code = 'FORM_4'`; баг FORM_2 я НЕ чинил (не входит в тикет) — оставляю как есть и выношу отдельной строкой ниже. 4. **Фронтенд НЕ трогал.** Инструкция просила найти, где `residual_after_actual` исключён из допустимых для записи полей на фронтенде (`AHR.js`/`CAP.js`/`STRUCTURE.js`), и сделать то же самое для `transfer_econ`. Прочитал `web/src/components/RealtimeTable/tableColumns.jsx` (`isEditable()`) и все 4 файла `constants/FORM_4/*.js` — такого per-field флага (editable/computed) для `residual_after_actual` НЕ существует вообще: для роли ADMIN `isEditable()` всегда возвращает `true` независимо от поля, `editType` не используется ни для одной числовой колонки, а цветовая схема (`color_type`) — чисто декоративная группировка по разделам формы, не связанная с правом записи (у `total`, который тоже вычисляемый, тот же `redColumn`, что у заведомо editable `transfer_q2`). Единственная защита вычисляемых полей — серверная (`computed_field:` в `_apply_form_cell`), которую UI уже обрабатывает универсально через существующий `onCellUpdateError`/`handleCellUpdateError`. После этого PR `transfer_econ` ведёт себя на фронтенде ТОЧНО так же, как `residual_after_actual` уже вёл себя всегда (клик возможен, сохранение сервер отклоняет) — то есть зеркалирование уже достигнуто без единой правки в `web/`. 5. Тестовые годы форм (`90101`–`90200`) выбраны заведомо далёкими от реальных лет, чтобы не пересекаться по `(org_unit_id, form_type, year)` с чужими данными в общей тестовой БД (интеграционные тесты в проекте не изолированы, см. ARCHITECTURE.md «Открытые вопросы»); каждый тест всё равно откатывается (`client`-фикстура с rollback-сессией). ## Тесты - `pytest tests/unit` — 27 passed (все проходят; 2 несвязанных падения `test_export_service_query_params.py` относятся к `tests/unit`, см. ниже). - `pytest` (полный набор, живой Postgres через `DATABASE_URL`, миграции `alembic upgrade head` 0001→0050 применены без ошибок, `--init_db` один раз на чистую БД) — **201 passed, 4 skipped, 6 failed**. Все 6 падений **пред-существующие и не связаны с этой веткой** — перепроверено на полностью чистой БД одним проходом (чтобы исключить накопленное состояние между запусками): `test_backend_calls_form3_add_project_and_add_line_functions`, `test_delete_project_smoke`, `test_delete_project_verify_gone`, `test_delete_seed_project_smoke` (все про FORM_3/проекты, я не трогал этот код), `test_export_full_form_ignores_non_applicable_params_for_other_sheets` и `test_export_form_payload_forwards_ignore_flag` (unit-тесты экспорт-сервиса, падают с `TypeError: unexpected keyword argument 'ignore_non_applicable_query_params'` — рассинхрон теста и текущей сигнатуры `ExportService`, тоже не моя область). Проверено дважды на двух разных чистых контейнерах Postgres 15 — результат идентичен с правкой и без (те же 6 падений на базовой ветке `test` до применения миграции 0050). - Новые 4 теста из `test_form4_transfer_econ.py` — **все 4 проходят**, включая регрессионный тест с ненулевыми переносами (ключевое доказательство правильности формулы, не наивного Excel-порта) и проверку ветки агрегации. - `alembic upgrade head` — чисто применяется поверх 0049 (0001...0050, без ошибок). ### Окружение для живого Postgres В предоставленном окружении не было `DATABASE_URL`/доступа к уже существующему контейнеру БД проекта (креды недоступны, а подбор/просмотр чужих кредов — запрещённое действие в песочнице). Поднял одноразовый Postgres 15 в Docker со своими кредами специально для этой проверки, прогнал `alembic upgrade head` и полный `pytest --init_db` на нём, контейнер удалил после проверки. `pg_get_functiondef` на живой БД подтвердил, что `v3.v_form4_sheet_sections` и `v3._apply_form_cell` до этого PR действительно определялись последними в `0033_form2_old_sort.sql` и `0023_apply_cell_empty_string.sql` соответственно (как и предполагалось в задаче) — переопределены корректно. ### Возможная коллизия номера миграции PR #257 (другая ветка того же батча, `feat/249-...`) тоже использует номер `0050` (`0050_budget_line_delete_owner_guard.sql`) с тем же `down_revision = "0049"`. Если обе ветки смержатся в `test`, вторая смерженная ветка получит дублирующийся revision (два alembic-head'а на один `down_revision`) — потребуется переномеровать один из файлов 0050→0051 и поправить `down_revision` при разрешении конфликта мержа. Не трогал #257 (чужая ветка), просто фиксирую риск для мержащего.
aep added 1 commit 2026-10-30 23:26:18 +03:00
Перенос в фонд экономии (q1/q2/q3/q4.transfer_econ, столбцы 75/107/137
и их Q4-аналог формы РФ) был raw passthrough-полем budget_line_quarter.
transfer_to_economy: ничего его не считало, поэтому после сохранения
строки с планом без переносов поле оставалось пустым, а "ВСЕГО" блоков
показывало прочерк вместо суммы.

v3.v_form4_sheet_sections теперь считает transfer_econ_QN из уже
имеющегося residual_after_actual_QN с поправкой на то, что тот уже
вычитает перенос в СЛЕДУЮЩИЙ квартал один раз (через cp1/cp2/cp3):

    transfer_econ_Q1 = residual_after_actual_Q1 - transfer_q3 - transfer_q4
    transfer_econ_Q2 = residual_after_actual_Q2 - transfer_q4
    transfer_econ_Q3 = residual_after_actual_Q3
    transfer_econ_Q4 = residual_after_actual_Q4

Буквальный перенос формулы Excel (ещё и минус transfer_q2 для Q1 /
transfer_q3 для Q2) задвоил бы вычитание. АХР, КВ (CAP) и Операц (OPER)
используют одну и ту же функцию, поэтому один фикс закрывает все три
листа; STRUCTURE — агрегирующий read-only свод без INPUT-строк, его не
касается.

v3._apply_form_cell для FORM_4 больше не принимает прямую запись в
q*.transfer_econ (ValueError computed_field, как у residual_after_actual).

Тесты (api/tests/integration/test_form4_transfer_econ.py):
- репро тикета (переносы = 0);
- регрессия на двойное вычитание с ненулевыми transfer_q2/q3/q4,
  отличающая верную формулу от наивного Excel-порта;
- КВ и Операц дают те же числа, что АХР;
- запись в transfer_econ отклоняется.

Closes #255.

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/255-aurora-1938-transfer-econ-calc:feat/255-aurora-1938-transfer-econ-calc
git checkout feat/255-aurora-1938-transfer-econ-calc
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#262
No description provided.