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:
parent
ac686991a8
commit
24062ed729
15
api/alembic/versions/0063_form2_transfer_econ_calc.py
Normal file
15
api/alembic/versions/0063_form2_transfer_econ_calc.py
Normal 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
|
||||||
1969
api/alembic/versions/sql/0063_form2_transfer_econ_calc.sql
Normal file
1969
api/alembic/versions/sql/0063_form2_transfer_econ_calc.sql
Normal file
File diff suppressed because one or more lines are too long
175
api/tests/integration/test_form2_transfer_econ.py
Normal file
175
api/tests/integration/test_form2_transfer_econ.py
Normal 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"]
|
||||||
@ -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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user