AURORA-1938: расчёт переноса в фонд экономии на листах АХР/КВ/Операц формы РФ #262
15
api/alembic/versions/0050_form4_transfer_econ_calc.py
Normal file
15
api/alembic/versions/0050_form4_transfer_econ_calc.py
Normal 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
|
||||
1277
api/alembic/versions/sql/0050_form4_transfer_econ_calc.sql
Normal file
1277
api/alembic/versions/sql/0050_form4_transfer_econ_calc.sql
Normal file
File diff suppressed because one or more lines are too long
233
api/tests/integration/test_form4_transfer_econ.py
Normal file
233
api/tests/integration/test_form4_transfer_econ.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user