fix(form2): фонд экономии (transfer_econ) считается на FORM_2 «Смета РФ», откат на FORM_4 (AURORA-1938, #335)

Миграция 0063: v_form2_sheet_sections считает q*.transfer_econ по шаблону Excel
((cp-aq) минус подколонки переноса минус переносы через квартал), total = остаток
после факта, групповые строки тем же; _apply_form_cell запрещает запись
q*.transfer_econ для FORM_2; v_form4_sheet_sections возвращена к версии 0033.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 16:04:12 +04:00
parent ac686991a8
commit 24062ed729
4 changed files with 2195 additions and 170 deletions

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0063"
down_revision = "0059"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0063_form2_transfer_econ_calc.sql")
def downgrade() -> None:
pass

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,175 @@
"""
AURORA-1938 / Gitea #335: «Смета РФ» в UI — это FORM_2 (а не FORM_4). Листы
АХР/КВ/Операц: перенос в фонд экономии (q1..q4.transfer_econ, столбцы 75/107/137)
считается, а не хранится, и входит в «Всего» (q*.total).
Формулы (шаблон Excel; cp — скорректированный план, aq — факт; в скобках
подколонки переноса «задержка актов / задержка закупок / экономия РФ»):
econ_Q1 = (cp1 - aq1) - (tda1 + tdp1 + terf1) - q1.transfer_q3 - q1.transfer_q4
econ_Q2 = (cp2 - aq2) - (tda2 + tdp2 + terf2) - q2.transfer_q4
econ_Q3 = (cp3 - aq3) - (tda3 + tdp3 + terf3)
econ_Q4 = (cp4 - aq4)
total_Qn = tda+tdp+terf + переносы через квартал + econ_Qn = cp_n - aq_n
Вычитаются именно подколонки (tda/tdp/terf), а не сырое q*.transfer_q2/q3/q4.
"""
import pytest
API = "/api/v1/form"
# листы формы 2 -> год формы (уникальный на тест)
SHEET_YEAR = {"AHR": 91001, "CAP": 91002, "OPER": 91003}
# родители АХР-сателлитов (аренда/коммуналка/охрана) — у них особая логика
AHR_SATELLITE_PARENTS = (24, 314, 779)
@pytest.fixture
def headers(admin_tokens, auth_headers):
return auth_headers(admin_tokens)
def _new_form(client, headers, year):
r = client.post(
f"{API}/",
json={"year": year, "form_type_code": "FORM_2", "org_unit_ids": [1]},
headers=headers,
)
assert r.status_code == 200, r.text
return int(r.json()["result"][0]["id"])
def _leaf_items(svod2_sql, sheet, n):
rows = svod2_sql(
"""
SELECT ei.id FROM v3.expense_item ei
JOIN v3.expense_item_form_type t ON t.expense_item_id = ei.id AND t.form_type_code = 'FORM_2'
WHERE ei.sheet = :sheet
AND NOT EXISTS (SELECT 1 FROM v3.expense_item c WHERE c.parent_id = ei.id)
AND ei.id <> ALL(:sat)
ORDER BY ei.id LIMIT :n
""",
{"sheet": sheet, "n": n, "sat": list(AHR_SATELLITE_PARENTS)},
)
assert len(rows) == n
return [r[0] for r in rows]
def _add_line(client, headers, form_id, sheet, item_id):
r = client.post(
f"{API}/{form_id}/sheet/{sheet}/line",
json={"expense_item_id": item_id},
headers=headers,
)
assert r.status_code == 200, r.text
return int(next(x for x in r.json()["result"] if x["row_type"] == "INPUT")["data"]["line_id"])
def _set(client, headers, form_id, sheet, line_id, column, value):
r = client.patch(
f"{API}/{form_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": column, "value": value},
headers=headers,
)
assert r.status_code == 200, r.text
def _rows(client, headers, form_id, sheet):
r = client.get(f"{API}/{form_id}/sheet/{sheet}", headers=headers)
assert r.status_code == 200, r.text
return r.json()["result"]
def _input(rows, line_id):
return next(x["data"] for x in rows if x["row_type"] == "INPUT" and x["data"].get("line_id") == line_id)
def _root(rows):
return next(x["data"] for x in rows if x["row_type"] == "ROOT")
@pytest.mark.parametrize("sheet", ["AHR", "CAP", "OPER"])
def test_form2_transfer_econ_ticket_repro(client, headers, svod2_sql, sheet):
"""Репро тикета: план 1000/2000/3000, остальное пусто."""
form_id = _new_form(client, headers, SHEET_YEAR[sheet])
line_id = _add_line(client, headers, form_id, sheet, _leaf_items(svod2_sql, sheet, 1)[0])
for col, val in [("plan.q1", 1000), ("plan.q2", 2000), ("plan.q3", 3000)]:
_set(client, headers, form_id, sheet, line_id, col, val)
d = _input(_rows(client, headers, form_id, sheet), line_id)
assert [d[q].get("transfer_econ") for q in ("q1", "q2", "q3")] == [1000, 2000, 3000]
assert [d[q]["total"] for q in ("q1", "q2", "q3")] == [1000, 2000, 3000]
# Q4: план не задан -> остаток 0
assert d["q4"].get("transfer_econ") == d["q4"]["residual_after_actual"] == 0
assert d["q4"]["total"] == 0
def test_form2_ahr_transfer_econ_subcolumns_and_far_transfers(client, headers, svod2_sql):
form_id = _new_form(client, headers, 91010)
line_id = _add_line(client, headers, form_id, "AHR", _leaf_items(svod2_sql, "AHR", 1)[0])
changes = [
("plan.q1", 1000), ("plan.q2", 2000), ("plan.q3", 3000),
("q1.transfer_q2_delay_acts", 100), ("q1.transfer_q3", 200), ("q1.transfer_q4", 300),
("q2.transfer_q3_delay_acts", 400), ("q2.transfer_q4", 500),
("q3.transfer_q4_delay_acts", 600),
]
for col, val in changes:
_set(client, headers, form_id, "AHR", line_id, col, val)
d = _input(_rows(client, headers, form_id, "AHR"), line_id)
# Q1: cp1=1000, aq1=0 -> 1000 - 100 - 200 - 300
assert d["q1"].get("transfer_econ") == 400
# Q2: np2 = ap2 + q1.transfer_q2 (сырое поле, не задано => 0) = 2000;
# cp2 - aq2 = 2000 -> 2000 - tda2(400) - q2.transfer_q4(500)
assert d["q2"].get("transfer_econ") == 1100
# Q3: np3 = ap3 + q1.transfer_q3(200) + q2.transfer_q3(сырое, 0) = 3200;
# 3200 - tda3(600)
assert d["q3"].get("transfer_econ") == 2600
# Q4: np4 = ap4(0) + q1.tq4(300) + q2.tq4(500) + q3.tq4(0) = 800; econ = 800
assert d["q4"].get("transfer_econ") == 800
# «Всего» = остаток после факта (cp - aq) этого квартала
assert d["q1"]["total"] == 1000 == d["q1"]["residual_after_actual"]
assert d["q2"]["total"] == 2000 == d["q2"]["residual_after_actual"]
assert d["q3"]["total"] == 3200 == d["q3"]["residual_after_actual"]
assert d["q4"]["total"] == 800 == d["q4"]["residual_after_actual"]
# ROOT (агрегат) = сумма по строкам; строка в форме одна
root = _root(_rows(client, headers, form_id, "AHR"))
for q in ("q1", "q2", "q3", "q4"):
assert root[q].get("transfer_econ") == d[q].get("transfer_econ"), q
assert root[q]["total"] == d[q]["total"], q
@pytest.mark.parametrize("sheet", ["AHR", "CAP", "OPER"])
def test_form2_transfer_econ_group_row_is_sum_of_lines(client, headers, svod2_sql, sheet):
form_id = _new_form(client, headers, SHEET_YEAR[sheet] + 100)
items = _leaf_items(svod2_sql, sheet, 2)
l1 = _add_line(client, headers, form_id, sheet, items[0])
l2 = _add_line(client, headers, form_id, sheet, items[1])
for col, val in [("plan.q1", 10), ("plan.q2", 20), ("plan.q3", 30)]:
_set(client, headers, form_id, sheet, l1, col, val)
for col, val in [("plan.q1", 1), ("plan.q2", 2), ("plan.q3", 3), ("q1.transfer_q4", 1)]:
_set(client, headers, form_id, sheet, l2, col, val)
rows = _rows(client, headers, form_id, sheet)
d1, d2, root = _input(rows, l1), _input(rows, l2), _root(rows)
assert (d1["q1"].get("transfer_econ"), d2["q1"].get("transfer_econ")) == (10, 0) # 1 - tq4(1)
for q in ("q1", "q2", "q3", "q4"):
assert root[q].get("transfer_econ") == d1[q].get("transfer_econ") + d2[q].get("transfer_econ"), q
assert root[q]["total"] == d1[q]["total"] + d2[q]["total"], q
@pytest.mark.parametrize("column", ["q1.transfer_econ", "q2.transfer_econ", "q3.transfer_econ", "q4.transfer_econ"])
def test_form2_transfer_econ_cell_is_read_only(client, headers, svod2_sql, column):
form_id = _new_form(client, headers, 91020)
line_id = _add_line(client, headers, form_id, "AHR", _leaf_items(svod2_sql, "AHR", 1)[0])
r = client.patch(
f"{API}/{form_id}/sheet/AHR/cell",
json={"line_id": line_id, "column": column, "value": 123},
headers=headers,
)
assert r.status_code in (400, 422), r.text
assert "computed_field" in r.json()["message"]

View File

@ -1,20 +1,11 @@
""" """
AURORA-1938 / Gitea #255: форма РФ (FORM_4), листы АХР/КВ/Операц — AURORA-1938 / Gitea #335: FORM_4 («Проектная деятельность») — откат PR #262.
"перенос в фонд экономии" (столбцы 75/107/137 = q1/q2/q3.transfer_econ)
не считался, был пустым raw passthrough-полем.
Формулы (поверх уже имеющегося в схеме residual_after_actual_QN, Фонд экономии (q1..q4.transfer_econ) считается на FORM_2 («Смета РФ»), см.
который уже вычитает перенос в СЛЕДУЮЩИЙ квартал ровно один раз через cp1/cp2/cp3): test_form2_transfer_econ.py. На FORM_4 поле осталось сырым
(budget_line_quarter.transfer_to_economy): его можно вводить руками, оно читается
transfer_econ_Q1 = residual_after_actual_Q1 - transfer_q3 - transfer_q4 как введено и входит в «Всего» по прежней формуле. Тест — регрессия на то, что
transfer_econ_Q2 = residual_after_actual_Q2 - transfer_q4 миграция 0063 откатила расчёт и запрет записи для FORM_4.
transfer_econ_Q3 = residual_after_actual_Q3
transfer_econ_Q4 = residual_after_actual_Q4
Буквальный перенос формулы Excel (CB9=BR9-BT9-BY9-BZ9, т.е. ещё и минус
transfer_q2) на residual_after_actual_Q1 был бы НЕВЕРНЫМ: residual_after_actual_Q1
уже учитывает transfer_q2 (через cp1), и его повторное вычитание занижает
экономию ровно на величину transfer_q2 (аналогично для Q2/transfer_q3 и Q3/transfer_q4).
""" """
import pytest import pytest
@ -67,178 +58,51 @@ def _get_input_row_data(client, admin_tokens, auth_headers, form_id, sheet, line
headers=auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
rows = response.json()["result"] for row in response.json()["result"]:
for row in rows:
if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id: if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id:
return row["data"] return row["data"]
pytest.fail(f"INPUT-строка для line_id={line_id} не найдена в ответе /sheet/{sheet}") pytest.fail(f"INPUT-строка для line_id={line_id} не найдена в ответе /sheet/{sheet}")
def _get_root_row_data(client, admin_tokens, auth_headers, form_id, sheet) -> dict: def test_form4_transfer_econ_is_raw_editable_field(client, admin_tokens, auth_headers):
"""ROOT-строка (depth=0) — агрегат по разделу; с единственной budget_line """План без ввода фонда: transfer_econ пуст (не считается); введённое руками
в разделе её q1..q4 должны совпадать с INPUT-строкой этой line — проверяет, значение сохраняется как есть и входит в «Всего» (старая формула)."""
что ветка агрегации (tw_agg/tw_aq1..4) считает transfer_econ так же, как
ветка INPUT, а не просто не падает."""
response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
rows = response.json()["result"]
for row in rows:
if row["row_type"] == "ROOT":
return row["data"]
pytest.fail(f"ROOT-строка не найдена в ответе /sheet/{sheet}")
def test_form4_ahr_transfer_econ_ticket_repro_zero_transfers(
client, admin_tokens, auth_headers
):
"""Репро тикета: план 1000/2000/3000 по кв. I-III, переносы/факт пустые.
Когда все transfer_qN == 0, корректная и наивная (Excel-буквальная) формулы
совпадают — этот тест один не доказывает правильность формулы, см.
test_form4_ahr_transfer_econ_with_forward_transfers_matches_non_naive_formula.
"""
form_id = _create_form4_form(client, admin_tokens, auth_headers, year=90101) form_id = _create_form4_form(client, admin_tokens, auth_headers, year=90101)
line_id = _add_line(client, admin_tokens, auth_headers, form_id, "AHR", 1) line_id = _add_line(client, admin_tokens, auth_headers, form_id, "AHR", 1)
for column, value in [("plan.q1", 1000), ("plan.q2", 2000), ("q1.transfer_q2", 100)]:
for column, value in [("plan.q1", 1000), ("plan.q2", 2000), ("plan.q3", 3000)]:
_set_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, column, value) _set_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, column, value)
data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, "AHR", line_id) data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, "AHR", line_id)
assert data["q1"].get("transfer_econ") is None
assert data["q2"].get("transfer_econ") is None
assert data["q1"]["total"] == 100 # только перенос в Q2, фонд не считается
assert data["q1"]["residual_after_actual"] == 1000 _set_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, "q1.transfer_econ", 123)
assert data["q2"]["residual_after_actual"] == 2000 _set_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, "q2.transfer_econ", 45)
assert data["q3"]["residual_after_actual"] == 3000
assert data["q1"]["transfer_econ"] == 1000
assert data["q2"]["transfer_econ"] == 2000
assert data["q3"]["transfer_econ"] == 3000
assert data["q1"]["total"] == 1000
assert data["q2"]["total"] == 2000
assert data["q3"]["total"] == 3000
def test_form4_ahr_transfer_econ_with_forward_transfers_matches_non_naive_formula(
client, admin_tokens, auth_headers
):
"""Регрессия на двойное вычитание: с ненулевыми переносами вперёд
(q1.transfer_q2=100, q2.transfer_q3=50, q3.transfer_q4=20) корректная и
наивная формулы расходятся — тест проверяет, что реализация считает
НЕ наивно (не дважды вычитает уже учтённый в residual_after_actual перенос).
"""
form_id = _create_form4_form(client, admin_tokens, auth_headers, year=90102)
line_id = _add_line(client, admin_tokens, auth_headers, form_id, "AHR", 1)
changes = [
("plan.q1", 1000),
("plan.q2", 2000),
("plan.q3", 3000),
("plan.q4", 500),
("q1.transfer_q2", 100),
("q2.transfer_q3", 50),
("q3.transfer_q4", 20),
]
for column, value in changes:
_set_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, column, value)
data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, "AHR", line_id) data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, "AHR", line_id)
assert data["q1"]["transfer_econ"] == 123
# residual_after_actual_QN уже минусует перенос в следующий квартал один раз assert data["q2"]["transfer_econ"] == 45
assert data["q1"]["residual_after_actual"] == 900 # 1000 - 100 assert data["q1"]["total"] == 100 + 123 # q1.transfer_q2 + transfer_econ
assert data["q2"]["residual_after_actual"] == 2050 # 2000 + 100 - 50 assert data["q2"]["total"] == 45
assert data["q3"]["residual_after_actual"] == 3030 # 3000 + 50 - 20
assert data["q4"]["residual_after_actual"] == 520 # 500 + 20
# Верная формула (НЕ наивный Excel-порт, который бы дал 800 / 2000 / 3010):
# te1 = residual_Q1 - tq3(0) - tq4(0) = 900
# te2 = residual_Q2 - tq4(0) = 2050
# te3 = residual_Q3 = 3030
# te4 = residual_Q4 = 520
assert data["q1"]["transfer_econ"] == 900
assert data["q2"]["transfer_econ"] == 2050
assert data["q3"]["transfer_econ"] == 3030
assert data["q4"]["transfer_econ"] == 520
# Наивная (Excel-буквальная, дважды вычитающая) формула дала бы другие числа —
# явно фиксируем, что реализация НЕ равна наивной.
naive_te1 = data["q1"]["residual_after_actual"] - 100 - 0 - 0 # = 800
naive_te2 = data["q2"]["residual_after_actual"] - 50 - 0 # = 2000
naive_te3 = data["q3"]["residual_after_actual"] - 20 # = 3010
assert data["q1"]["transfer_econ"] != naive_te1
assert data["q2"]["transfer_econ"] != naive_te2
assert data["q3"]["transfer_econ"] != naive_te3
# ВСЕГО блоков учитывает рассчитанный transfer_econ и равен полному
# (ещё не разнесённому по кварталам) остатку после факта.
assert data["q1"]["total"] == 100 + 0 + 0 + 900 # = 1000
assert data["q2"]["total"] == 50 + 0 + 2050 # = 2100
assert data["q3"]["total"] == 20 + 3030 # = 3050
assert data["q4"]["total"] == 520
# Ветка агрегации (ROOT) считает transfer_econ так же, как ветка INPUT, а
# не просто не падает: с единственной budget_line в разделе ROOT.q1..q4
# должны совпасть с INPUT-строкой этой line.
root_data = _get_root_row_data(client, admin_tokens, auth_headers, form_id, "AHR")
assert root_data["q1"]["transfer_econ"] == data["q1"]["transfer_econ"] == 900
assert root_data["q2"]["transfer_econ"] == data["q2"]["transfer_econ"] == 2050
assert root_data["q3"]["transfer_econ"] == data["q3"]["transfer_econ"] == 3030
assert root_data["q4"]["transfer_econ"] == data["q4"]["transfer_econ"] == 520
def test_form4_cap_and_oper_transfer_econ_use_same_formula_as_ahr( @pytest.mark.parametrize("sheet", ["CAP", "OPER"])
client, admin_tokens, auth_headers def test_form4_cap_oper_transfer_econ_is_raw_editable_field(client, admin_tokens, auth_headers, sheet):
): expense_item_id = FORM4_EXPENSE_ITEM_BY_SHEET[sheet]
"""КВ (CAP) и Операц (OPER) используют ту же v3.v_form4_sheet_sections, year = 90200 + FORM4_YEAR_OFFSET_BY_SHEET[sheet]
что и АХР — проверяем, что расчёт даёт те же числа на этих листах.""" form_id = _create_form4_form(client, admin_tokens, auth_headers, year=year)
for sheet in ("CAP", "OPER"): line_id = _add_line(client, admin_tokens, auth_headers, form_id, sheet, expense_item_id)
expense_item_id = FORM4_EXPENSE_ITEM_BY_SHEET[sheet] _set_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, "plan.q1", 1000)
year = 90200 + FORM4_YEAR_OFFSET_BY_SHEET[sheet] _set_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, "q1.transfer_econ", 77)
form_id = _create_form4_form(client, admin_tokens, auth_headers, year=year)
line_id = _add_line(client, admin_tokens, auth_headers, form_id, sheet, expense_item_id)
changes = [ data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, sheet, line_id)
("plan.q1", 1000), assert data["q1"]["transfer_econ"] == 77
("plan.q2", 2000),
("plan.q3", 3000),
("q1.transfer_q2", 100),
("q2.transfer_q3", 50),
]
for column, value in changes:
_set_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, column, value)
data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, sheet, line_id)
assert data["q1"]["transfer_econ"] == 900, sheet # 1000 - 100
assert data["q2"]["transfer_econ"] == 2050, sheet # 2000 + 100 - 50
assert data["q3"]["transfer_econ"] == 3050, sheet # 3000 + 50
def test_form4_ahr_transfer_econ_cell_is_read_only(client, admin_tokens, auth_headers): async def test_apply_form_cell_guard_is_form2_only_and_keeps_spod():
"""transfer_econ должен стать вычисляемым полем (как residual_after_actual) — """0063 пересобирает v3._apply_form_cell поверх 0055: запрет transfer_econ — только
прямая запись в q1.transfer_econ для FORM_4 должна быть отклонена.""" для FORM_2, СПОД (AURORA-1906, 0050) на месте."""
form_id = _create_form4_form(client, admin_tokens, auth_headers, year=90103)
line_id = _add_line(client, admin_tokens, auth_headers, form_id, "AHR", 1)
response = client.patch(
f"/api/v1/form/{form_id}/sheet/AHR/cell",
json={"line_id": line_id, "column": "q1.transfer_econ", "value": 123},
headers=auth_headers(admin_tokens),
)
assert response.status_code in (400, 422), response.text
payload = response.json()
assert "message" in payload
assert "computed_field" in payload["message"] or "transfer_econ" in payload["message"]
async def test_apply_form_cell_keeps_aurora_1906_spod_and_aurora_1938_guard():
"""Регрессия на порядок миграций: 0055 (AURORA-1938) пересобирает
v3._apply_form_cell поверх 0050_form2_ahr_spod_column (AURORA-1906), а не
поверх старого тела 0023 — иначе CREATE OR REPLACE тихо откатил бы СПОД.
Проверяем итоговое определение функции в БД: в нём одновременно есть
разрешение fact_q4.spod (1906) и запрет ручной записи transfer_econ (1938)."""
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
@ -261,4 +125,6 @@ async def test_apply_form_cell_keeps_aurora_1906_spod_and_aurora_1938_guard():
await engine.dispose() await engine.dispose()
assert "'oct','nov','dec','spod'" in definition, "AURORA-1906 (СПОД) откачен" assert "'oct','nov','dec','spod'" in definition, "AURORA-1906 (СПОД) откачен"
assert "v_field = 'transfer_econ'" in definition, "AURORA-1938 (computed transfer_econ) потерян" guard = definition[definition.index("v_field = 'transfer_econ'"):]
assert "v_form_type = 'FORM_2'" in guard[:400]
assert "v_form_type = 'FORM_4'" not in guard[:400]