fix(form4): перенумеровать миграцию 1938 в 0055 и пересобрать _apply_form_cell поверх 0050 (СПОД, AURORA-1906)

Миграция 0050_form4_transfer_econ_calc -> 0055 (down 0054): номер 0050 занят
на test. Тело v3._apply_form_cell = актуальное тело из 0050_form2_ahr_spod_column
+ только дельта AURORA-1938 (запрет ручной записи FORM_4 q*.transfer_econ);
раньше копия опиралась на 0023 и откатывала СПОД. v_form4_sheet_sections
после 0033 на test не менялась — тело без изменений. Добавлен тест на сохранение
обеих правок в итоговом определении функции.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 09:36:25 +04:00
parent 37c2b96d61
commit e75ed6d44f
3 changed files with 44 additions and 10 deletions

View File

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

View File

@ -11,6 +11,9 @@
-- Буквальный Excel-перенос (ещё и минус transfer_q2 для Q1 / transfer_q3 для Q2)
-- задваивал бы вычитание — residual_after_actual_QN уже его учитывает.
--
-- 0055: пересобрано поверх 0050_form2_ahr_spod_column (AURORA-1906): тело _apply_form_cell = актуальное
-- на test + только дельта AURORA-1938 (v_form4_sheet_sections на test после 0033 не менялась).
--
-- 2) v3._apply_form_cell: для FORM_4 q*.transfer_econ больше нельзя писать напрямую —
-- поле стало вычисляемым, как residual_after_actual.
@ -900,7 +903,7 @@ BEGIN
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
IF (v_target_type = 'NUMERIC' OR v_target_type = 'INTEGER') AND p_value = '""'::jsonb THEN
p_value := '"0"'::jsonb;
END IF;
@ -940,7 +943,7 @@ BEGIN
IF (v_parts[1]='fact_q1' AND v_parts[2] NOT IN ('jan','feb','mar')) OR
(v_parts[1]='fact_q2' AND v_parts[2] NOT IN ('apr','may','jun')) OR
(v_parts[1]='fact_q3' AND v_parts[2] NOT IN ('jul','aug','sep')) OR
(v_parts[1]='fact_q4' AND v_parts[2] NOT IN ('oct','nov','dec')) THEN
(v_parts[1]='fact_q4' AND v_parts[2] NOT IN ('oct','nov','dec','spod')) THEN
RAISE EXCEPTION 'unknown_column: % (месяц вне квартала)', p_column;
END IF;
v_target_col := 'actual_' || v_parts[2];
@ -949,7 +952,7 @@ BEGIN
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
IF v_target_type = 'NUMERIC' AND p_value = '""'::jsonb THEN
p_value := '"0"'::jsonb;
END IF;
@ -1228,9 +1231,9 @@ BEGIN
ELSIF v_key_kind = 'line_quarter' THEN
IF v_q IS NULL THEN RAISE EXCEPTION 'internal: quarter not set for %', p_column; END IF;
IF v_target_type = 'DATE' THEN
v_sql := format(
'INSERT INTO v3.%1$I (line_id, quarter, %2$I) VALUES ($1, $2, to_date($3->>0, ''DD.MM.YYYY'')) '
|| 'ON CONFLICT (line_id, quarter) DO UPDATE SET %2$I = EXCLUDED.%2$I',
@ -1245,12 +1248,12 @@ BEGIN
);
END IF;
EXECUTE v_sql USING p_line_id, v_q, p_value;
ELSIF v_key_kind = 'line_actor' THEN
IF v_actor IS NULL THEN RAISE EXCEPTION 'internal: actor not set for %', p_column; END IF;
IF v_target_type = 'DATE' THEN
v_sql := format(
'INSERT INTO v3.%1$I (line_id, actor, %2$I) VALUES ($1, $2, to_date($3->>0, ''DD.MM.YYYY'')) '

View File

@ -231,3 +231,34 @@ def test_form4_ahr_transfer_econ_cell_is_read_only(client, admin_tokens, auth_he
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.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from src.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
try:
async with engine.connect() as conn:
definition = (
await conn.execute(
text(
"SELECT pg_get_functiondef(p.oid) FROM pg_proc p "
"JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE n.nspname = 'v3' AND p.proname = '_apply_form_cell'"
)
)
).scalar_one()
finally:
await engine.dispose()
assert "'oct','nov','dec','spod'" in definition, "AURORA-1906 (СПОД) откачен"
assert "v_field = 'transfer_econ'" in definition, "AURORA-1938 (computed transfer_econ) потерян"