fix(form4): рассчитывать перенос в фонд экономии на листах АХР/КВ/Операц

Перенос в фонд экономии (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 commit is contained in:
Andrey Epifancev 2026-10-31 00:24:43 +04:00
parent 2a287480b6
commit 66ceb6c5b2
3 changed files with 1525 additions and 0 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,233 @@
"""
AURORA-1938 / Gitea #255: форма РФ (FORM_4), листы АХР/КВ/Операц —
"перенос в фонд экономии" (столбцы 75/107/137 = q1/q2/q3.transfer_econ)
не считался, был пустым raw passthrough-полем.
Формулы (поверх уже имеющегося в схеме 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 (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
FORM4_EXPENSE_ITEM_BY_SHEET = {
# Любой конкретный expense_item, привязанный к FORM_4 и нужному листу
# (см. v3.expense_item / v3.expense_item_form_type в сидах миграций).
"AHR": 1,
"CAP": 523,
"OPER": 729,
}
FORM4_YEAR_OFFSET_BY_SHEET = {"AHR": 0, "CAP": 1, "OPER": 2}
def _create_form4_form(client, admin_tokens, auth_headers, year: int) -> int:
response = client.post(
"/api/v1/form/",
json={"year": year, "form_type_code": "FORM_4", "org_unit_ids": [2]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
return int(response.json()["result"][0]["id"])
def _add_line(client, admin_tokens, auth_headers, form_id: int, sheet: str, expense_item_id: int) -> int:
response = client.post(
f"/api/v1/form/{form_id}/sheet/{sheet}/line",
json={"expense_item_id": expense_item_id},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
rows = response.json()["result"]
input_row = next(r for r in rows if r["row_type"] == "INPUT")
return int(input_row["data"]["line_id"])
def _set_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, column, value):
response = client.patch(
f"/api/v1/form/{form_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": column, "value": value},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
return response
def _get_input_row_data(client, admin_tokens, auth_headers, form_id, sheet, line_id) -> dict:
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"] == "INPUT" and row["data"].get("line_id") == line_id:
return row["data"]
pytest.fail(f"INPUT-строка для line_id={line_id} не найдена в ответе /sheet/{sheet}")
def _get_root_row_data(client, admin_tokens, auth_headers, form_id, sheet) -> dict:
"""ROOT-строка (depth=0) — агрегат по разделу; с единственной budget_line
в разделе её 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)
line_id = _add_line(client, admin_tokens, auth_headers, form_id, "AHR", 1)
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)
data = _get_input_row_data(client, admin_tokens, auth_headers, form_id, "AHR", line_id)
assert data["q1"]["residual_after_actual"] == 1000
assert data["q2"]["residual_after_actual"] == 2000
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)
# residual_after_actual_QN уже минусует перенос в следующий квартал один раз
assert data["q1"]["residual_after_actual"] == 900 # 1000 - 100
assert data["q2"]["residual_after_actual"] == 2050 # 2000 + 100 - 50
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(
client, admin_tokens, auth_headers
):
"""КВ (CAP) и Операц (OPER) используют ту же v3.v_form4_sheet_sections,
что и АХР — проверяем, что расчёт даёт те же числа на этих листах."""
for sheet in ("CAP", "OPER"):
expense_item_id = FORM4_EXPENSE_ITEM_BY_SHEET[sheet]
year = 90200 + FORM4_YEAR_OFFSET_BY_SHEET[sheet]
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 = [
("plan.q1", 1000),
("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):
"""transfer_econ должен стать вычисляемым полем (как residual_after_actual) —
прямая запись в q1.transfer_econ для FORM_4 должна быть отклонена."""
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"]