feat(form2): колонка «СПОД» в сателлитах АХР (аренда/коммуналка/охрана)

Добавлен столбец «СПОД» между «Декабрь» и «Итог IV кв.» на листах
AHR_RENT/AHR_UTILITY/AHR_SECURITY формы 2, с переносом значения в
budget_line_quarter.actual_spod синхронизируемой строки АХР (Support)
и, через уже существующую логику, в итог 4 кв. и года АХР/Смет/сводов.

- rent_detail/utility_detail/security_detail: колонка actual_spod
  (ALTER TABLE + ORM).
- v3.v_form2_view: fact_q4.spod и fact_q4.total с учётом spod в ветках
  AHR_RENT/AHR_UTILITY/AHR_SECURITY.
- v3._apply_form_cell: fact_q4.spod разрешён как обычное поле месяца.
- v3._upsert_form2_ahr_detail_sync_values: DROP+CREATE с новым
  параметром p_actual_spod (DROP обязателен из-за неоднозначности
  перегрузки с DEFAULT).
- v3.sync_form2_ahr_satellite_lines/line: actual_spod передаётся из
  rd/ud/sd в апсерт.
- v3.v_svod2_go: строка аренды также считается заполненной по
  actual_spod.
- Data-миграция: 'fact_q4.spod' добавлен в column_keys этапов, где уже
  есть 'fact_q4.dec' (phase_template/form_phase), идемпотентно.
- Тело CREATE OR REPLACE для v_form2_view/sync_form2_ahr_satellite_*
  собрано вручную по миграциям 0033/0034 + regexp-патчам из 0037
  (pg_get_functiondef на живой БД недоступен в этой среде — см.
  Rulings в PR).

Тесты: node --test на новых accessorKey в конфигах фронта + unit-тест
на 0049/0050 (v_svod2_go теперь засчитывает actual_spod для аренды).
Интеграционные тесты добавлены, но не выполнены — нет доступа к
Postgres в этой рабочей среде (см. Rulings в PR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-31 00:24:56 +04:00
parent 2a287480b6
commit d41668dc27
11 changed files with 2000 additions and 11 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_form2_ahr_spod_column.sql")
def downgrade() -> None:
pass

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ class RentDetail(Base):
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -37,6 +37,7 @@ class SecurityDetail(Base):
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -37,6 +37,7 @@ class UtilityDetail(Base):
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,194 @@
"""AURORA-1906: колонка «СПОД» в сателлитах АХР (RENT/UTILITY/SECURITY) формы 2
и её перенос в АХР (Support) через v3.sync_form2_ahr_satellite_line(s).
Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py
client fixture), поэтому изменения не переживают отдельный тест.
"""
import pytest
# parent-строка в АХР для каждого сателлита — budget_line с этим
# expense_item_id (v3.add_budget_line / v3.sync_form2_ahr_satellite_line,
# api/alembic/versions/sql/0034_ahr_rf_sort_order.sql).
SATELLITE_PARENT_EXPENSE_ITEM = {
"AHR_RENT": 314,
"AHR_UTILITY": 24,
"AHR_SECURITY": 779,
}
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _get_form2_id(client, admin_tokens) -> int | None:
response = client.get(
"/api/v1/form/",
params={"form_type": "FORM_2"},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
forms = response.json()["result"]
return forms[0]["id"] if forms else None
@pytest.fixture
def form2_id(client, admin_tokens):
form_id = _get_form2_id(client, admin_tokens)
if form_id is None:
pytest.skip("В фикстуре нет формы 2 (FORM_2) — тест СПОД пропущен")
return form_id
def _add_satellite_line(client, admin_tokens, form2_id, sheet, vsp_id=1):
response = client.post(
f"/api/v1/form/{form2_id}/sheet/{sheet}/line",
json={"vsp_id": vsp_id},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
rows = response.json()["result"]
assert len(rows) == 1
return rows[0]["data"]["id"]
def _get_sheet_rows(client, admin_tokens, form2_id, sheet) -> list[dict]:
response = client.get(
f"/api/v1/form/{form2_id}/sheet/{sheet}",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
return response.json()["result"]
def _find_row(rows: list[dict], predicate) -> dict:
for row in rows:
if predicate(row["data"]):
return row
raise AssertionError("строка не найдена")
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
def test_satellite_spod_written_and_included_in_q4_total(
client, admin_tokens, form2_id, sheet
):
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
rows = _get_sheet_rows(client, admin_tokens, form2_id, sheet)
row = _find_row(rows, lambda d: d["id"] == line_id)
oct_ = row["data"]["fact_q4"]["oct"] or 0
nov_ = row["data"]["fact_q4"]["nov"] or 0
dec_ = row["data"]["fact_q4"]["dec"] or 0
response = client.patch(
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
rows = _get_sheet_rows(client, admin_tokens, form2_id, sheet)
row = _find_row(rows, lambda d: d["id"] == line_id)
assert row["data"]["fact_q4"]["spod"] == 5
assert row["data"]["fact_q4"]["total"] == oct_ + nov_ + dec_ + 5
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
def test_satellite_spod_syncs_into_ahr_support_quarter(
client, admin_tokens, form2_id, sheet
):
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
ahr_row_before = _find_row(
ahr_rows,
lambda d: d.get("header", {}).get("expense_item_id")
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
)
actual_quarter_before = ahr_row_before["data"]["q4"]["actual_quarter"] or 0
fact_year_before = ahr_row_before["data"]["totals"]["fact_year"] or 0
response = client.patch(
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
ahr_row_after = _find_row(
ahr_rows,
lambda d: d.get("header", {}).get("expense_item_id")
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
)
assert ahr_row_after["data"]["q4"]["actual_spod"] == 5
assert ahr_row_after["data"]["q4"]["actual_quarter"] == actual_quarter_before + 5
assert ahr_row_after["data"]["totals"]["fact_year"] == fact_year_before + 5
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
def test_satellite_spod_rejects_other_quarters(
client, admin_tokens, form2_id, sheet
):
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
response = client.patch(
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": "fact_q1.spod", "value": 5},
headers=_auth_headers(admin_tokens),
)
assert response.status_code in (400, 422), response.text
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
def test_satellite_spod_phase_gates_non_admin_user(
client, admin_tokens, isp_tokens, form2_id, sheet
):
"""DFIP-пользователь (isp1): без 'fact_q4.spod' в этапе — отказ,
после появления ключа (как после data-миграции) — успех."""
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
phase_code = f"spod_test_{sheet.lower()}"
create_body = {
"budget_form_id": form2_id,
"sheet": sheet,
"phase_code": phase_code,
"role": "DFIP",
"column_keys": ["fact_q4.dec"],
"opens_at": "2020-01-01T00:00:00",
"closes_at": "2099-01-01T00:00:00",
"direction": None,
}
response = client.post(
f"/api/v1/stages/form/{form2_id}",
json=create_body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
response = client.patch(
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
headers=_auth_headers(isp_tokens),
)
assert response.status_code in (400, 403, 409), response.text
response = client.patch(
f"/api/v1/stages/form/{form2_id}/{sheet}/{phase_code}",
json={**create_body, "column_keys": ["fact_q4.dec", "fact_q4.spod"]},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
response = client.patch(
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 200, response.text
response = client.delete(
f"/api/v1/stages/form/{form2_id}/{sheet}/{phase_code}",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text

View File

@ -7,6 +7,10 @@ from pathlib import Path
SQL_PATH = Path(__file__).resolve().parents[2] / "alembic/versions/sql/0049_summary2_related_records.sql"
# AURORA-1906 (0050) переопределяет v_svod2_go целиком (CREATE OR REPLACE) и
# добавляет туда проверку rent_detail.actual_spod — патч применяется поверх
# 0049 ниже (см. setUp), v_svod2_rf/v_svod2_go_export 0050 не трогает.
SQL_PATCH_PATH = Path(__file__).resolve().parents[2] / "alembic/versions/sql/0050_form2_ahr_spod_column.sql"
TABLES = (
"plan", "reserve", "contract_summary", "allocation", "collegial_approval",
"ckk", "contract_detail", "sequestration", "booking", "budget_line_quarter",
@ -58,18 +62,28 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
columns += ", expenses_q1, expenses_q2, expenses_q3, expenses_q4, vat_rate, deadline"
self.db.execute(f"CREATE TABLE v3.{table} ({columns})")
# Execute the eligibility SELECTs, adapting PostgreSQL casts, arrays and regex syntax.
self.queries = []
for part in SQL_PATH.read_text().split("eligible_lines AS (")[1:]:
query = part.split("), ", 1)[0].replace("::varchar", "")
def adapt(query):
query = query.replace("::varchar", "")
query = re.sub(r"(presence_\w+\.\w+) ~ '\[\^\[:space:\]\]'",
r"has_text(\1)", query)
query = query.replace("p_year", ":year")
query = query.replace("cardinality(p_org_unit_ids)", "1")
query = query.replace("ANY(p_org_unit_ids)", ":org_id")
query = query.replace("p_org_unit_ids", ":org_id")
self.queries.append(query)
return query
self.queries = [
adapt(part.split("), ", 1)[0])
for part in SQL_PATH.read_text().split("eligible_lines AS (")[1:]
]
self.assertEqual(len(self.queries), 3)
# v_svod2_go (index 0) is redefined whole by 0050 (CREATE OR REPLACE),
# which is the current source of truth for its eligible_lines CTE.
patch_parts = SQL_PATCH_PATH.read_text().split("eligible_lines AS (")[1:]
if patch_parts:
self.queries[0] = adapt(patch_parts[0].split("), ", 1)[0])
def rows(self, index, year=2026, org_id=None):
return self.db.execute(self.queries[index], {"year": year, "org_id": org_id}).fetchall()
@ -118,6 +132,11 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
self.db.execute("DELETE FROM v3.reserve")
def test_each_meaningful_plan_quarter_or_rent_field_qualifies(self):
# AURORA-1906 (0050) added rent_detail.actual_spod and made only
# v_svod2_go (index 0) qualify a line by it — v_svod2_rf/
# v_svod2_go_export (index 1/2) were out of that ticket's scope and
# still don't check actual_spod (known gap, see PR Rulings).
unpatched_spod_indexes = {1, 2}
for index in range(3):
self.db.execute("UPDATE v3.budget_form SET form_type_code = ?",
("FORM_2" if index == 1 else "FORM_1",))
@ -127,7 +146,12 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
for value in values:
with self.subTest(query=index, table=table, field=field, value=value):
self.db.execute(f"INSERT INTO v3.{table}(line_id, {field}) VALUES (1, ?)", (value,))
self.assertEqual(len(self.rows(index)), 1)
expected = (
0
if table == "rent_detail" and field == "actual_spod" and index in unpatched_spod_indexes
else 1
)
self.assertEqual(len(self.rows(index)), expected)
self.db.execute(f"DELETE FROM v3.{table}")
def test_source_year_org_and_sheet_filters_are_preserved(self):

View File

@ -118,9 +118,9 @@ export const config = {
color_type: whiteColumn,
accessorKey: 'data.fact_q4.dec',
},
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
'data.fact_q4.spod': {
color_type: whiteColumn,
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
accessorKey: 'data.fact_q4.spod',
},
'data.fact_q4.total': {
color_type: whiteColumn,
@ -903,6 +903,32 @@ export const config = {
},
},
{
header: 'СПОД',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
columns: [
{
header: '',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
columns: [
{
header: 'В тыс. рублей',
accessorKey: 'data.fact_q4.spod',
columnLetter: 'AR',
size: 150,
filterFn: 'contains',
},
],
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: '#FFFFFF',
color: '#000000',
},
},
},
{
header: 'Итог IV кв.',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',

View File

@ -110,9 +110,9 @@ export const config = {
color_type: whiteColumn,
accessorKey: 'data.fact_q4.dec',
},
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
'data.fact_q4.spod': {
color_type: whiteColumn,
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
accessorKey: 'data.fact_q4.spod',
},
'data.fact_q4.total': {
color_type: whiteColumn,
@ -857,6 +857,32 @@ export const config = {
},
},
{
header: 'СПОД',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
columns: [
{
header: '',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
columns: [
{
header: 'В тыс. рублей',
accessorKey: 'data.fact_q4.spod',
columnLetter: 'AP',
size: 150,
filterFn: 'contains',
},
],
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: '#FFFFFF',
color: '#000000',
},
},
},
{
header: 'Итог IV кв.',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',

View File

@ -110,9 +110,9 @@ export const config = {
color_type: whiteColumn,
accessorKey: 'data.fact_q4.dec',
},
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
'data.fact_q4.spod': {
color_type: whiteColumn,
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
accessorKey: 'data.fact_q4.spod',
},
'data.fact_q4.total': {
color_type: whiteColumn,
@ -858,6 +858,32 @@ export const config = {
},
},
{
header: 'СПОД',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
columns: [
{
header: '',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
columns: [
{
header: 'В тыс. рублей',
accessorKey: 'data.fact_q4.spod',
columnLetter: 'AP',
size: 150,
filterFn: 'contains',
},
],
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: '#FFFFFF',
color: '#000000',
},
},
},
{
header: 'Итог IV кв.',
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',

View File

@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// AHR_RENT.js / AHR_UTILITY.js / AHR_SECURITY.js импортируют '../columnColors'
// без расширения, поэтому node не может загрузить модуль напрямую — читаем
// файл как текст и вытаскиваем accessorKey листьев регэкспом (см.
// form2SmetaConfig.test.js, AURORA-1901, для образца этого подхода).
const SHEETS = ['AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY'];
function readConfigSource(sheet) {
const configPath = fileURLToPath(new URL(`../constants/FORM_2/${sheet}.js`, import.meta.url));
return readFileSync(configPath, 'utf8');
}
function extractLeafAccessorKeys(source) {
const matches = source.matchAll(/accessorKey:\s*'(data\.[^']+)'/g);
return [...matches].map((m) => m[1]);
}
for (const sheet of SHEETS) {
test(`${sheet}: колонка СПОД стоит между Декабрём и Итогом IV кв.`, () => {
const keys = extractLeafAccessorKeys(readConfigSource(sheet));
const decIndex = keys.indexOf('data.fact_q4.dec');
const spodIndex = keys.indexOf('data.fact_q4.spod');
const totalIndex = keys.indexOf('data.fact_q4.total');
assert.notEqual(decIndex, -1, 'data.fact_q4.dec должен присутствовать в конфиге');
assert.notEqual(spodIndex, -1, 'data.fact_q4.spod должен присутствовать в конфиге');
assert.notEqual(totalIndex, -1, 'data.fact_q4.total должен присутствовать в конфиге');
assert.deepEqual(
[decIndex, spodIndex, totalIndex],
[decIndex, decIndex + 1, decIndex + 2],
'ожидается порядок ..., data.fact_q4.dec, data.fact_q4.spod, data.fact_q4.total',
);
});
test(`${sheet}: колонка СПОД не оставляет висящий ключ в colors`, () => {
const source = readConfigSource(sheet);
assert.ok(
!source.includes('fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley'),
'старый висящий ключ цвета СПОД должен быть заменён на data.fact_q4.spod',
);
});
}