From d41668dc27128f749c2aaf7d3ab06112e64df966 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 00:24:56 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(form2):=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=C2=AB=D0=A1=D0=9F=D0=9E=D0=94=C2=BB=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=B0=D1=82=D0=B5=D0=BB=D0=BB=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=85=20=D0=90=D0=A5=D0=A0=20(=D0=B0=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0/=D0=BA=D0=BE=D0=BC=D0=BC=D1=83=D0=BD=D0=B0=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0/=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен столбец «СПОД» между «Декабрь» и «Итог 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 --- .../versions/0050_form2_ahr_spod_column.py | 15 + .../sql/0050_form2_ahr_spod_column.sql | 1628 +++++++++++++++++ api/src/db/models/rent_detail.py | 1 + api/src/db/models/security_detail.py | 1 + api/src/db/models/utility_detail.py | 1 + ...st_form2_ahr_satellite_spod_integration.py | 194 ++ .../unit/test_summary2_related_records_sql.py | 34 +- .../constants/FORM_2/AHR_RENT.js | 30 +- .../constants/FORM_2/AHR_SECURITY.js | 30 +- .../constants/FORM_2/AHR_UTILITY.js | 30 +- .../utils/form2AhrSatelliteSpod.test.js | 47 + 11 files changed, 2000 insertions(+), 11 deletions(-) create mode 100644 api/alembic/versions/0050_form2_ahr_spod_column.py create mode 100644 api/alembic/versions/sql/0050_form2_ahr_spod_column.sql create mode 100644 api/tests/integration/test_form2_ahr_satellite_spod_integration.py create mode 100644 web/src/components/RealtimeTable/utils/form2AhrSatelliteSpod.test.js diff --git a/api/alembic/versions/0050_form2_ahr_spod_column.py b/api/alembic/versions/0050_form2_ahr_spod_column.py new file mode 100644 index 0000000..017cb4e --- /dev/null +++ b/api/alembic/versions/0050_form2_ahr_spod_column.py @@ -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 diff --git a/api/alembic/versions/sql/0050_form2_ahr_spod_column.sql b/api/alembic/versions/sql/0050_form2_ahr_spod_column.sql new file mode 100644 index 0000000..f96f337 --- /dev/null +++ b/api/alembic/versions/sql/0050_form2_ahr_spod_column.sql @@ -0,0 +1,1628 @@ +-- AURORA-1906: добавить столбец «СПОД» в листы АХР Аренда / Коммунальные +-- услуги / Безопасность (сателлиты FORM_2 AHR_RENT/AHR_UTILITY/AHR_SECURITY), +-- с переносом в АХР «СПОД (январь)» (v3.budget_line_quarter.actual_spod). +-- +-- CREATE OR REPLACE для v3.v_form2_view, v3.sync_form2_ahr_satellite_lines и +-- v3.sync_form2_ahr_satellite_line ниже воспроизводят тело функций, актуальное +-- на голове миграций этой ветки (0049): 0034/0033 как базовый текст + +-- рантайм-патчи 0037_form2_ahr_limit_depth_fix.sql (оба DO-блока: замена +-- формулы qty*limit_with_vat на v3._form2_ahr_limit_amount(...) и ослабление +-- матчера depth=3 до (depth=3 AND item_id=...) OR num_group_id=...). Ничего +-- после 0037 и до 0049 эти функции больше не трогает (проверено по всем +-- *.sql миграциям 0038..0049) — патчи применены вручную по тем же regexp, +-- что в 0037, без доступа к live DB для pg_get_functiondef (см. Rulings в PR). + +-- 1. Новая колонка в сателлитах АХР. +ALTER TABLE v3.rent_detail ADD COLUMN IF NOT EXISTS actual_spod numeric; +ALTER TABLE v3.utility_detail ADD COLUMN IF NOT EXISTS actual_spod numeric; +ALTER TABLE v3.security_detail ADD COLUMN IF NOT EXISTS actual_spod numeric; + + +-- 2. v3.v_form2_view: fact_q4.spod в ветках AHR_RENT/AHR_UTILITY/AHR_SECURITY. +CREATE OR REPLACE FUNCTION v3.v_form2_view( + p_form_id INT, + p_sheet VARCHAR, + p_sections TEXT[] DEFAULT NULL +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +BEGIN + + + IF p_sheet IN ('AHR','CAP','OPER') THEN + RETURN QUERY + SELECT + j.row_type, + j.depth, + ROW_NUMBER() OVER (ORDER BY j._sort_path) AS sort_order, + jsonb_build_object( + 'line_id', j.line_id, + 'header', j.header, + 'plan', j.plan_data, + 'seq_dfip', j.seq_dfip_data, + 'seq_ssp', j.seq_ssp_data, + 'approved', j.approved_data, + 'contract', j.contract_data, + 'booking', j.booking_data, + 'q1', j.q1_data, + 'q2', j.q2_data, + 'q3', j.q3_data, + 'q4', j.q4_data, + 'totals', j.totals_data + ) AS data + FROM v3.v_form2_sheet_jsonb(p_form_id, p_sheet, p_sections) j + ORDER BY j._sort_path; + RETURN; + END IF; + + + IF p_sheet = 'AHR_LIMIT' THEN + RETURN QUERY + WITH RECURSIVE tree AS ( + SELECT id AS root_id, id AS desc_id FROM v3.limit_template + UNION + SELECT t.root_id, child.id + FROM tree t + JOIN v3.limit_template child ON child.parent_id = t.desc_id + ), + leaf_amt AS ( + + SELECT lt.id, + fl.qty_q1, fl.qty_q2, fl.qty_q3, fl.qty_q4, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q1) AS amount_q1, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q2) AS amount_q2, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q3) AS amount_q3, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q4) AS amount_q4 + FROM v3.limit_template lt + LEFT JOIN v3.form_limit fl + ON fl.template_id = lt.id AND fl.budget_form_id = p_form_id + WHERE lt.row_type = 'LEAF' + ), + agg AS ( + SELECT t.root_id, + SUM(la.qty_q1) AS qty_q1, SUM(la.amount_q1) AS amount_q1, + SUM(la.qty_q2) AS qty_q2, SUM(la.amount_q2) AS amount_q2, + SUM(la.qty_q3) AS qty_q3, SUM(la.amount_q3) AS amount_q3, + SUM(la.qty_q4) AS qty_q4, SUM(la.amount_q4) AS amount_q4 + FROM tree t + JOIN leaf_amt la ON la.id = t.desc_id + GROUP BY t.root_id + ) + SELECT + + CASE lt.row_type WHEN 'SECTION' THEN 'ROOT' + WHEN 'GROUP' THEN 'GROUP' + WHEN 'LEAF' THEN 'INPUT' END::VARCHAR, + CASE lt.row_type WHEN 'SECTION' THEN 0 WHEN 'GROUP' THEN 1 ELSE 2 END, + lt.sort_order::BIGINT, + jsonb_build_object( + 'id', lt.id, + 'section_no', lt.section_no, + 'expense_item_code',lt.expense_item_code, + 'name', lt.name, + 'unit', lt.unit, + 'limit_with_vat', lt.limit_with_vat, + 'limit_without_vat',lt.limit_without_vat, + 'qty_q1', a.qty_q1, 'amount_q1', a.amount_q1, + 'qty_q2', a.qty_q2, 'amount_q2', a.amount_q2, + 'qty_q3', a.qty_q3, 'amount_q3', a.amount_q3, + 'qty_q4', a.qty_q4, 'amount_q4', a.amount_q4, + 'comment', fl.comment + ) + FROM v3.limit_template lt + LEFT JOIN agg a ON a.root_id = lt.id + LEFT JOIN v3.form_limit fl + ON fl.template_id = lt.id AND fl.budget_form_id = p_form_id + ORDER BY lt.sort_order; + RETURN; + END IF; + + + IF p_sheet = 'AHR_RENT' THEN + RETURN QUERY + SELECT + 'INPUT'::VARCHAR, + 0, + ROW_NUMBER() OVER (ORDER BY rd.id) AS sort_order, + jsonb_build_object( + 'id', rd.id, + 'line_id', rd.line_id, + 'vsp_id', rd.vsp_id, + 'address', v.address, + 'object_type', v.format, + 'rented_area', v.total_area, + 'contract_number', rd.contract_number, + 'contract_end_date',rd.contract_end_date, + 'plan', jsonb_build_object('q1', rd.plan_q1, 'q2', rd.plan_q2, 'q3', rd.plan_q3, 'q4', rd.plan_q4, + 'year', COALESCE(rd.plan_q1,0)+COALESCE(rd.plan_q2,0)+COALESCE(rd.plan_q3,0)+COALESCE(rd.plan_q4,0)), + 'fact_q1', jsonb_build_object('jan', rd.actual_jan, 'feb', rd.actual_feb, 'mar', rd.actual_mar, + 'total', COALESCE(rd.actual_jan,0)+COALESCE(rd.actual_feb,0)+COALESCE(rd.actual_mar,0)), + 'fact_q2', jsonb_build_object('apr', rd.actual_apr, 'may', rd.actual_may, 'jun', rd.actual_jun, + 'total', COALESCE(rd.actual_apr,0)+COALESCE(rd.actual_may,0)+COALESCE(rd.actual_jun,0)), + 'fact_q3', jsonb_build_object('jul', rd.actual_jul, 'aug', rd.actual_aug, 'sep', rd.actual_sep, + 'total', COALESCE(rd.actual_jul,0)+COALESCE(rd.actual_aug,0)+COALESCE(rd.actual_sep,0)), + 'fact_q4', jsonb_build_object('oct', rd.actual_oct, 'nov', rd.actual_nov, 'dec', rd.actual_dec, 'spod', rd.actual_spod, + 'total', COALESCE(rd.actual_oct,0)+COALESCE(rd.actual_nov,0)+COALESCE(rd.actual_dec,0)+COALESCE(rd.actual_spod,0)), + 'comment', rd.comment + ) + FROM v3.rent_detail rd + JOIN v3.budget_line bl ON bl.id = rd.line_id + JOIN v3.vsp v ON v.id = rd.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY rd.id; + RETURN; + END IF; + + + IF p_sheet = 'AHR_UTILITY' THEN + RETURN QUERY + SELECT + 'INPUT'::VARCHAR, + 0, + ROW_NUMBER() OVER (ORDER BY ud.id) AS sort_order, + jsonb_build_object( + 'id', ud.id, + 'line_id', ud.line_id, + 'vsp_id', ud.vsp_id, + 'address', v.address, + 'object_type', v.format, + 'object_area', v.total_area, + 'contract_number', ud.contract_number, + 'contract_end_date',ud.contract_end_date, + 'plan', jsonb_build_object('q1', ud.plan_q1, 'q2', ud.plan_q2, 'q3', ud.plan_q3, 'q4', ud.plan_q4, + 'year', COALESCE(ud.plan_q1,0)+COALESCE(ud.plan_q2,0)+COALESCE(ud.plan_q3,0)+COALESCE(ud.plan_q4,0)), + 'fact_q1', jsonb_build_object('jan', ud.actual_jan, 'feb', ud.actual_feb, 'mar', ud.actual_mar, + 'total', COALESCE(ud.actual_jan,0)+COALESCE(ud.actual_feb,0)+COALESCE(ud.actual_mar,0)), + 'fact_q2', jsonb_build_object('apr', ud.actual_apr, 'may', ud.actual_may, 'jun', ud.actual_jun, + 'total', COALESCE(ud.actual_apr,0)+COALESCE(ud.actual_may,0)+COALESCE(ud.actual_jun,0)), + 'fact_q3', jsonb_build_object('jul', ud.actual_jul, 'aug', ud.actual_aug, 'sep', ud.actual_sep, + 'total', COALESCE(ud.actual_jul,0)+COALESCE(ud.actual_aug,0)+COALESCE(ud.actual_sep,0)), + 'fact_q4', jsonb_build_object('oct', ud.actual_oct, 'nov', ud.actual_nov, 'dec', ud.actual_dec, 'spod', ud.actual_spod, + 'total', COALESCE(ud.actual_oct,0)+COALESCE(ud.actual_nov,0)+COALESCE(ud.actual_dec,0)+COALESCE(ud.actual_spod,0)), + 'comment', ud.comment + ) + FROM v3.utility_detail ud + JOIN v3.budget_line bl ON bl.id = ud.line_id + JOIN v3.vsp v ON v.id = ud.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY ud.id; + RETURN; + END IF; + + + IF p_sheet = 'AHR_SECURITY' THEN + RETURN QUERY + SELECT + 'INPUT'::VARCHAR, + 0, + ROW_NUMBER() OVER (ORDER BY sd.id) AS sort_order, + jsonb_build_object( + 'id', sd.id, + 'line_id', sd.line_id, + 'vsp_id', sd.vsp_id, + 'address', v.address, + 'object_type', v.format, + 'object_area', v.total_area, + 'contract_number', sd.contract_number, + 'contract_end_date',sd.contract_end_date, + 'plan', jsonb_build_object('q1', sd.plan_q1, 'q2', sd.plan_q2, 'q3', sd.plan_q3, 'q4', sd.plan_q4, + 'year', COALESCE(sd.plan_q1,0)+COALESCE(sd.plan_q2,0)+COALESCE(sd.plan_q3,0)+COALESCE(sd.plan_q4,0)), + 'fact_q1', jsonb_build_object('jan', sd.actual_jan, 'feb', sd.actual_feb, 'mar', sd.actual_mar, + 'total', COALESCE(sd.actual_jan,0)+COALESCE(sd.actual_feb,0)+COALESCE(sd.actual_mar,0)), + 'fact_q2', jsonb_build_object('apr', sd.actual_apr, 'may', sd.actual_may, 'jun', sd.actual_jun, + 'total', COALESCE(sd.actual_apr,0)+COALESCE(sd.actual_may,0)+COALESCE(sd.actual_jun,0)), + 'fact_q3', jsonb_build_object('jul', sd.actual_jul, 'aug', sd.actual_aug, 'sep', sd.actual_sep, + 'total', COALESCE(sd.actual_jul,0)+COALESCE(sd.actual_aug,0)+COALESCE(sd.actual_sep,0)), + 'fact_q4', jsonb_build_object('oct', sd.actual_oct, 'nov', sd.actual_nov, 'dec', sd.actual_dec, 'spod', sd.actual_spod, + 'total', COALESCE(sd.actual_oct,0)+COALESCE(sd.actual_nov,0)+COALESCE(sd.actual_dec,0)+COALESCE(sd.actual_spod,0)), + 'comment', sd.comment + ) + FROM v3.security_detail sd + JOIN v3.budget_line bl ON bl.id = sd.line_id + JOIN v3.vsp v ON v.id = sd.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY sd.id; + RETURN; + END IF; + + + IF p_sheet = 'OTCH9F' THEN + RETURN QUERY + WITH base AS ( + SELECT far.id, far.budget_form_id, far.expense_item_id, + far.equipment_name, far.month, + far.acquired_qty_604, far.acquired_amt_604, + far.disposed_qty_604, far.disposed_amt_604, + far.acquired_qty_60415, far.acquired_amt_60415, + far.transferred_qty_60415, far.transferred_amt_60415, + far.go_balance_only_amt, + + FIRST_VALUE(COALESCE(far.opening_qty_604,0)) + OVER w AS base_qty_604, + FIRST_VALUE(COALESCE(far.opening_amt_604,0)) + OVER w AS base_amt_604, + FIRST_VALUE(COALESCE(far.opening_qty_60415,0)) + OVER w AS base_qty_60415, + FIRST_VALUE(COALESCE(far.opening_amt_60415,0)) + OVER w AS base_amt_60415, + + COALESCE(SUM(COALESCE(far.acquired_qty_604,0) - COALESCE(far.disposed_qty_604,0)) + OVER w_prev, 0) AS cum_dq_604, + COALESCE(SUM(COALESCE(far.acquired_amt_604,0) - COALESCE(far.disposed_amt_604,0)) + OVER w_prev, 0) AS cum_da_604, + COALESCE(SUM(COALESCE(far.acquired_qty_60415,0) - COALESCE(far.transferred_qty_60415,0)) + OVER w_prev, 0) AS cum_dq_60415, + COALESCE(SUM(COALESCE(far.acquired_amt_60415,0) - COALESCE(far.transferred_amt_60415,0)) + OVER w_prev, 0) AS cum_da_60415 + FROM v3.fixed_asset_report far + WHERE far.budget_form_id = p_form_id + WINDOW w AS (PARTITION BY far.budget_form_id, far.expense_item_id, far.equipment_name + ORDER BY far.month), + w_prev AS (PARTITION BY far.budget_form_id, far.expense_item_id, far.equipment_name + ORDER BY far.month + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) + ), + eff AS ( + SELECT b.*, + (b.base_qty_604 + b.cum_dq_604) AS opening_qty_604_eff, + (b.base_amt_604 + b.cum_da_604) AS opening_amt_604_eff, + (b.base_qty_60415 + b.cum_dq_60415) AS opening_qty_60415_eff, + (b.base_amt_60415 + b.cum_da_60415) AS opening_amt_60415_eff + FROM base b + ) + SELECT + 'INPUT'::VARCHAR, + 0, + ROW_NUMBER() OVER (ORDER BY eff.expense_item_id, eff.month) AS sort_order, + jsonb_build_object( + 'id', eff.id, + 'expense_item_id', ei.id, + 'item_id', ei.item_id, + 'section_code', ei.section_code, + 'equipment_name', eff.equipment_name, + 'month', eff.month, + 'b_604', jsonb_build_object( + 'opening_qty', eff.opening_qty_604_eff, + 'opening_amt', eff.opening_amt_604_eff, + 'acquired_qty', eff.acquired_qty_604, + 'acquired_amt', eff.acquired_amt_604, + 'disposed_qty', eff.disposed_qty_604, + 'disposed_amt', eff.disposed_amt_604 + ), + 'b_60415', jsonb_build_object( + 'opening_qty', eff.opening_qty_60415_eff, + 'opening_amt', eff.opening_amt_60415_eff, + 'acquired_qty', eff.acquired_qty_60415, + 'acquired_amt', eff.acquired_amt_60415, + 'transferred_qty',eff.transferred_qty_60415, + 'transferred_amt',eff.transferred_amt_60415 + ), + 'totals', jsonb_build_object( + + 'acquired_total', COALESCE(eff.acquired_amt_604,0)+COALESCE(eff.acquired_amt_60415,0) + +COALESCE(eff.go_balance_only_amt,0)-COALESCE(eff.transferred_amt_60415,0), + 'go_balance_only',eff.go_balance_only_amt, + + 'disposed_total', COALESCE(eff.disposed_amt_604,0), + + 'balance_qty', (eff.opening_qty_604_eff + COALESCE(eff.acquired_qty_604,0) - COALESCE(eff.disposed_qty_604,0)) + + (eff.opening_qty_60415_eff + COALESCE(eff.acquired_qty_60415,0) - COALESCE(eff.transferred_qty_60415,0)), + 'balance_amt', (eff.opening_amt_604_eff + COALESCE(eff.acquired_amt_604,0) - COALESCE(eff.disposed_amt_604,0)) + + (eff.opening_amt_60415_eff + COALESCE(eff.acquired_amt_60415,0) - COALESCE(eff.transferred_amt_60415,0)) + ) + ) + FROM eff + JOIN v3.expense_item ei ON ei.id = eff.expense_item_id + ORDER BY eff.expense_item_id, eff.month; + RETURN; + END IF; + + + IF p_sheet = 'SMETA' THEN + RETURN QUERY + SELECT + sm.row_type, + sm.depth, + ROW_NUMBER() OVER (ORDER BY sm.section_code) AS sort_order, + jsonb_build_object( + 'section_code', sm.section_code, + 'name', sm.name, + 'plan', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.plan_supp_q1, 'q2', sm.plan_supp_q2, 'q3', sm.plan_supp_q3, 'q4', sm.plan_supp_q4, 'year', sm.plan_supp_year), + 'development', jsonb_build_object('q1', sm.plan_dev_q1, 'q2', sm.plan_dev_q2, 'q3', sm.plan_dev_q3, 'q4', sm.plan_dev_q4, 'year', sm.plan_dev_year) + ), + 'approved', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.appr_supp_q1, 'q2', sm.appr_supp_q2, 'q3', sm.appr_supp_q3, 'q4', sm.appr_supp_q4, 'year', sm.appr_supp_year), + 'development', jsonb_build_object('q1', sm.appr_dev_q1, 'q2', sm.appr_dev_q2, 'q3', sm.appr_dev_q3, 'q4', sm.appr_dev_q4, 'year', sm.appr_dev_year) + ), + 'fact', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.fact_supp_q1, 'q2', sm.fact_supp_q2, 'q3', sm.fact_supp_q3, 'q4', sm.fact_supp_q4, 'year', sm.fact_supp_year), + 'development', jsonb_build_object('q1', sm.fact_dev_q1, 'q2', sm.fact_dev_q2, 'q3', sm.fact_dev_q3, 'q4', sm.fact_dev_q4, 'year', sm.fact_dev_year) + ), + + 'corrected', jsonb_build_object( + 'support', jsonb_build_object('q2', sm.corr_supp_q2, 'q3', sm.corr_supp_q3, 'q4', sm.corr_supp_q4), + 'development', jsonb_build_object('q2', sm.corr_dev_q2, 'q3', sm.corr_dev_q3, 'q4', sm.corr_dev_q4) + ) + ) + FROM v3.v_form2_smeta(p_form_id) sm + ORDER BY sm.section_code; + RETURN; + END IF; + + RAISE EXCEPTION 'Unknown sheet: %', p_sheet + USING HINT = 'Use AHR / CAP / OPER / AHR_LIMIT / AHR_RENT / AHR_UTILITY / AHR_SECURITY / OTCH9F / SMETA'; + +END; +$function$ +LANGUAGE plpgsql STABLE; + + +-- 3. v3._apply_form_cell: разрешить fact_q4.spod для сателлитов АХР. +CREATE OR REPLACE FUNCTION v3._apply_form_cell(p_line_id integer, p_column text, p_value jsonb, p_sheet character varying DEFAULT NULL::character varying, p_form_id integer DEFAULT NULL::integer) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_parts TEXT[]; + v_scope TEXT; + v_field TEXT; + v_form_type TEXT; + v_q SMALLINT; + v_actor TEXT; + v_target_table TEXT; + v_target_col TEXT; + v_target_type TEXT; + v_key_kind TEXT; + v_str TEXT; + v_sql TEXT; + v_sat_table TEXT; + v_cnt INT; + +BEGIN + v_parts := string_to_array(p_column, '.'); + + IF p_sheet = 'OTCH9F' THEN + DECLARE v_far_month SMALLINT; + BEGIN + SELECT month INTO v_far_month FROM v3.fixed_asset_report WHERE id = p_line_id; + IF v_far_month IS NULL THEN + RAISE EXCEPTION 'fixed_asset_report.id=% не существует', p_line_id; + END IF; + + IF array_length(v_parts,1) = 1 THEN + IF v_parts[1] = 'equipment_name' THEN + RAISE EXCEPTION 'normative_field: equipment_name (нормативная номенклатура, правится отдельным API)'; + ELSIF v_parts[1] IN ('month','expense_item_id','item_id','section_code','id') THEN + RAISE EXCEPTION 'key_field: % (часть ключа строки, RO)', v_parts[1]; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + END IF; + + IF array_length(v_parts,1) <> 2 THEN + RAISE EXCEPTION 'bad_column_format: %', p_column; + END IF; + + IF v_parts[1] = 'totals' THEN + IF v_parts[2] = 'go_balance_only' THEN + v_target_col := 'go_balance_only_amt'; v_target_type := 'NUMERIC'; + ELSIF v_parts[2] IN ('acquired_total','disposed_total','balance_qty','balance_amt') THEN + RAISE EXCEPTION 'computed_field: totals.%', v_parts[2]; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + ELSIF v_parts[1] IN ('b_604','b_60415') THEN + IF v_parts[2] LIKE 'opening_%' AND v_far_month <> 1 THEN + RAISE EXCEPTION 'computed_field: % (opening для month>1 вычисляется из base + накопит. дельты)', p_column; + END IF; + IF v_parts[1] = 'b_604' THEN + v_target_col := CASE v_parts[2] + WHEN 'opening_qty' THEN 'opening_qty_604' + WHEN 'opening_amt' THEN 'opening_amt_604' + WHEN 'acquired_qty' THEN 'acquired_qty_604' + WHEN 'acquired_amt' THEN 'acquired_amt_604' + WHEN 'disposed_qty' THEN 'disposed_qty_604' + WHEN 'disposed_amt' THEN 'disposed_amt_604' + END; + IF v_target_col IS NULL THEN + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END; + ELSE -- b_60415 + v_target_col := CASE v_parts[2] + WHEN 'opening_qty' THEN 'opening_qty_60415' + WHEN 'opening_amt' THEN 'opening_amt_60415' + WHEN 'acquired_qty' THEN 'acquired_qty_60415' + WHEN 'acquired_amt' THEN 'acquired_amt_60415' + WHEN 'transferred_qty' THEN 'transferred_qty_60415' + WHEN 'transferred_amt' THEN 'transferred_amt_60415' + END; + IF v_target_col IS NULL THEN + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END; + END IF; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + EXECUTE format( + 'UPDATE v3.fixed_asset_report SET %I = ($2 #>> ''{}'')::%s WHERE id = $1', + v_target_col, v_target_type + ) USING p_line_id, p_value; + RETURN; + END; + END IF; + + IF p_sheet = 'AHR_LIMIT' THEN + IF p_form_id IS NULL THEN + RAISE EXCEPTION 'internal: p_form_id не передан для AHR_LIMIT'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM v3.limit_template lt + WHERE lt.id = p_line_id AND lt.row_type = 'LEAF') THEN + RAISE EXCEPTION 'limit_template.id=% не существует или не LEAF (SECTION/GROUP не редактируется)', p_line_id; + END IF; + + IF array_length(v_parts,1) <> 1 THEN + RAISE EXCEPTION 'bad_column_format: %, expected single key', p_column; + END IF; + + CASE v_parts[1] + WHEN 'qty_q1','qty_q2','qty_q3','qty_q4' THEN + v_target_col := v_parts[1]; v_target_type := 'INTEGER'; + WHEN 'comment' THEN + v_target_col := 'comment'; v_target_type := 'TEXT'; + WHEN 'amount_q1','amount_q2','amount_q3','amount_q4' THEN + RAISE EXCEPTION 'computed_field: % (вычисляется из qty × limit × period_factor)', p_column; + WHEN 'name','unit','section_no','expense_item_code', + 'limit_with_vat','limit_without_vat' THEN + RAISE EXCEPTION 'normative_field: % (нормативный справочник, правится админом отдельно)', p_column; + 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; + + EXECUTE format( + 'INSERT INTO v3.form_limit (budget_form_id, template_id, %1$I) ' + || 'VALUES ($1, $2, ($3 #>> ''{}'')::%2$s) ' + || 'ON CONFLICT (budget_form_id, template_id) DO UPDATE SET %1$I = EXCLUDED.%1$I', + v_target_col, v_target_type + ) USING p_form_id, p_line_id, p_value; + RETURN; + END IF; + + IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN + v_sat_table := CASE p_sheet + WHEN 'AHR_RENT' THEN 'rent_detail' + WHEN 'AHR_UTILITY' THEN 'utility_detail' + WHEN 'AHR_SECURITY' THEN 'security_detail' + END; + + IF array_length(v_parts,1) = 1 THEN + CASE v_parts[1] + WHEN 'contract_number' THEN v_target_col := 'contract_number'; v_target_type := 'TEXT'; + WHEN 'contract_end_date' THEN v_target_col := 'contract_end_date'; v_target_type := 'DATE'; + WHEN 'comment' THEN v_target_col := 'comment'; v_target_type := 'TEXT'; + WHEN 'address','object_type','rented_area','object_area' THEN + RAISE EXCEPTION 'computed_field: % (атрибут v3.vsp, правится отдельно)', p_column; + ELSE RAISE EXCEPTION 'unknown_column: %', p_column; + END CASE; + ELSIF array_length(v_parts,1) = 2 AND v_parts[1] = 'plan' THEN + IF v_parts[2] = 'year' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF; + IF v_parts[2] NOT IN ('q1','q2','q3','q4') THEN RAISE EXCEPTION 'unknown_column: %', p_column; END IF; + v_target_col := 'plan_' || v_parts[2]; + v_target_type := 'NUMERIC'; + ELSIF array_length(v_parts,1) = 2 AND v_parts[1] LIKE 'fact_q%' THEN + IF v_parts[2] = 'total' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF; + 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','spod')) THEN + RAISE EXCEPTION 'unknown_column: % (месяц вне квартала)', p_column; + END IF; + v_target_col := 'actual_' || v_parts[2]; + v_target_type := 'NUMERIC'; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + + IF v_target_type = 'NUMERIC' AND p_value = '""'::jsonb THEN + p_value := '"0"'::jsonb; + END IF; + + EXECUTE format( + 'UPDATE v3.%I SET %I = ($2 #>> ''{}'')::%s WHERE id = $1', + v_sat_table, v_target_col, v_target_type + ) USING p_line_id, p_value; + + GET DIAGNOSTICS v_cnt = ROW_COUNT; + IF v_cnt = 0 THEN + RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_line_id; + END IF; + RETURN; + END IF; + + IF array_length(v_parts, 1) < 2 THEN + RAISE EXCEPTION 'bad_column_format: %, expected scope.field', p_column; + END IF; + v_scope := v_parts[1]; + v_field := v_parts[2]; + + IF v_scope = 'header' THEN + IF v_field = 'name' THEN + SELECT form_type_code INTO v_form_type from v3.budget_form where id = p_form_id; + IF v_form_type = 'FORM_4' THEN + RAISE EXCEPTION 'Нельзя редактировать поле name'; + END IF; + UPDATE v3.budget_line SET name = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'internal_order' THEN + UPDATE v3.budget_line SET internal_order = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'justification' THEN + UPDATE v3.budget_line SET justification = (p_value #>> '{}')::TEXT, updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'vsp_id' THEN + UPDATE v3.budget_line SET vsp_id = (p_value #>> '{}')::INT, updated_at = now() WHERE id = p_line_id; + ELSIF v_field IN ('year','section','item_id','num_group','vsp_address') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + RETURN; + END IF; + + IF v_scope IN ('approved','totals') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + END IF; + + IF v_scope = 'booking' THEN + IF array_length(v_parts, 1) <> 3 THEN + RAISE EXCEPTION 'bad_column_format: %, expected booking.y{2026|2027}.qN', p_column; + END IF; + IF v_parts[2] NOT IN ('y2026','y2027') THEN + RAISE EXCEPTION 'bad_booking_year: % (only y2026/y2027)', v_parts[2]; + END IF; + IF v_parts[3] NOT IN ('q1','q2','q3','q4') THEN + RAISE EXCEPTION 'bad_booking_quarter: %', v_parts[3]; + END IF; + v_target_col := CASE v_parts[2] + WHEN 'y2026' THEN 'expenses_' || v_parts[3] + WHEN 'y2027' THEN 'expenses_next_year_' || v_parts[3] + END; + v_sql := format( + 'INSERT INTO v3.ckk (line_id, %1$I) VALUES ($1, ($2 #>> ''{}'')::NUMERIC) ' + || 'ON CONFLICT (line_id) DO UPDATE SET %1$I = EXCLUDED.%1$I', + v_target_col + ); + EXECUTE v_sql USING p_line_id, p_value; + UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id; + RETURN; + END IF; + + + IF v_scope IN ('q1','q2','q3','q4') THEN + v_q := substring(v_scope FROM 2)::SMALLINT; + ELSE + v_q := NULL; + END IF; + + v_actor := CASE v_scope + WHEN 'sequestration' THEN 'DFIP' + WHEN 'seq_dfip' THEN 'DFIP' + WHEN 'seq_ssp' THEN 'SSP_GO' + ELSE NULL END; + + SELECT m.target_table, m.target_col, m.target_type, m.key_kind + INTO v_target_table, v_target_col, v_target_type, v_key_kind + FROM (VALUES + ('plan','q1', 'plan','plan_q1','NUMERIC','line'), + ('plan','q2', 'plan','plan_q2','NUMERIC','line'), + ('plan','q3', 'plan','plan_q3','NUMERIC','line'), + ('plan','q4', 'plan','plan_q4','NUMERIC','line'), + ('plan','comment', 'plan','comment','TEXT', 'line'), + + ('reserve','q1', 'reserve','amount_q1','NUMERIC','line'), + ('reserve','q2', 'reserve','amount_q2','NUMERIC','line'), + ('reserve','q3', 'reserve','amount_q3','NUMERIC','line'), + ('reserve','q4', 'reserve','amount_q4','NUMERIC','line'), + ('reserve','justification','reserve','justification','TEXT','line'), + + ('allocation','order', 'allocation','internal_order','TEXT','line'), + ('allocation','property', 'allocation','property_object','TEXT','line'), + ('allocation','contract_ref', 'allocation','contract_ref','TEXT','line'), + ('allocation','allocation_purpose', 'allocation','allocation_purpose','TEXT','line'), + + ('contract_summary','total', 'contract_summary','total_amount', 'NUMERIC','line'), + ('contract_summary','counterparty', 'contract_summary','counterparty', 'TEXT', 'line'), + ('contract_summary','deadline', 'contract_summary','deadline', 'TEXT', 'line'), + ('contract_summary','comment', 'contract_summary','comment', 'TEXT', 'line'), + ('contract_summary','future_y1', 'contract_summary','future_payments_y1', 'NUMERIC','line'), + ('contract_summary','future_y2', 'contract_summary','future_payments_y2', 'NUMERIC','line'), + ('contract_summary','other_ssp', 'contract_summary','other_ssp_amount', 'NUMERIC','line'), + ('contract_summary','reference', 'contract_summary','reference', 'TEXT','line'), + ('contract_summary','centralized_flag', 'contract_summary','centralized_flag', 'TEXT','line'), + + ('collegial','approved', 'collegial_approval','approved_amount', 'NUMERIC','line'), + ('collegial','protocol', 'collegial_approval','protocol_reference','TEXT', 'line'), + ('collegial','note', 'collegial_approval','note', 'TEXT', 'line'), + + ('ckk','ceiling', 'ckk','ceiling_amount', 'NUMERIC','line'), + ('ckk','q1', 'ckk','expenses_q1', 'NUMERIC','line'), + ('ckk','q2', 'ckk','expenses_q2', 'NUMERIC','line'), + ('ckk','q3', 'ckk','expenses_q3', 'NUMERIC','line'), + ('ckk','q4', 'ckk','expenses_q4', 'NUMERIC','line'), + ('ckk','next_q1', 'ckk','expenses_next_year_q1', 'NUMERIC','line'), + ('ckk','next_q2', 'ckk','expenses_next_year_q2', 'NUMERIC','line'), + ('ckk','next_q3', 'ckk','expenses_next_year_q3', 'NUMERIC','line'), + ('ckk','next_q4', 'ckk','expenses_next_year_q4', 'NUMERIC','line'), + ('ckk','rf_schedule', 'ckk','rf_schedule', 'TEXT', 'line'), + ('ckk','deadline', 'ckk','delivery_deadline', 'TEXT', 'line'), + ('ckk','proc_plan', 'ckk','procurement_plan', 'TEXT', 'line'), + ('ckk','proc_method', 'ckk','procurement_method','TEXT', 'line'), + ('ckk','comment', 'ckk','comment', 'TEXT', 'line'), + + ('contract_detail','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'), + ('contract_detail','reference', 'contract_detail','reference', 'TEXT', 'line'), + ('contract_detail','addenda', 'contract_detail','addenda', 'TEXT', 'line'), + ('contract_detail','subject', 'contract_detail','subject', 'TEXT', 'line'), + ('contract_detail','currency', 'contract_detail','currency', 'TEXT', 'line'), + ('contract_detail','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'), + ('contract_detail','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'), + ('contract_detail','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'), + ('contract_detail','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'), + ('contract_detail','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'), + ('contract_detail','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'), + ('contract_detail','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'), + ('contract_detail','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'), + ('contract_detail','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'), + ('contract_detail','deadline', 'contract_detail','deadline', 'TEXT', 'line'), + ('contract_detail','payment_scheme', 'contract_detail','payment_scheme','TEXT', 'line'), + ('contract_detail','act', 'contract_detail','act', 'TEXT', 'line'), + ('contract_detail','comment', 'contract_detail','comment', 'TEXT', 'line'), + ('contract_detail','contract_date', 'contract_detail','contract_date', 'DATE', 'line'), + + ('contract','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'), + ('contract','addenda', 'contract_detail','addenda', 'TEXT', 'line'), + ('contract','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'), + ('contract','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'), + ('contract','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'), + ('contract','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'), + ('contract','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'), + ('contract','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'), + ('contract','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'), + ('contract','reference', 'contract_detail','reference', 'TEXT', 'line'), + ('contract','date', 'contract_detail','contract_date', 'DATE', 'line'), + ('contract','subject', 'contract_detail','subject', 'TEXT', 'line'), + ('contract','currency', 'contract_detail','currency', 'TEXT', 'line'), + ('contract','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'), + ('contract','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'), + ('contract','deadline', 'contract_detail','deadline', 'TEXT', 'line'), + ('contract','scheme', 'contract_detail','payment_scheme','TEXT', 'line'), + ('contract','act', 'contract_detail','act', 'TEXT', 'line'), + ('contract','comment', 'contract_detail','comment', 'TEXT', 'line'), + + ('sequestration','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('sequestration','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('sequestration','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('sequestration','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('sequestration','justification','sequestration','justification','TEXT', 'line_actor'), + + ('seq_dfip','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('seq_dfip','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('seq_dfip','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('seq_dfip','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('seq_dfip','justification','sequestration','justification','TEXT', 'line_actor'), + + ('seq_ssp','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('seq_ssp','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('seq_ssp','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('seq_ssp','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('seq_ssp','justification','sequestration','justification','TEXT', 'line_actor'), + + ('q','adj_current', 'budget_line_quarter','adj_current', 'NUMERIC','line_quarter'), + ('q','adj_ssp', 'budget_line_quarter','adj_ssp', 'NUMERIC','line_quarter'), + ('q','adj_rf', 'budget_line_quarter','adj_rf', 'NUMERIC','line_quarter'), + ('q','adj_reserve', 'budget_line_quarter','adj_reserve', 'NUMERIC','line_quarter'), + ('q','adj_comment', 'budget_line_quarter','adj_comment', 'TEXT', 'line_quarter'), + ('q','target_change', 'budget_line_quarter','target_change', 'NUMERIC','line_quarter'), + ('q','base_correction', 'budget_line_quarter','base_plan_correction', 'NUMERIC','line_quarter'), + ('q','base_correction_comment', 'budget_line_quarter','base_plan_correction_comment','TEXT', 'line_quarter'), + ('q','pay_date', 'budget_line_quarter','payment_date', 'DATE', 'line_quarter'), + ('q','pay_amount', 'budget_line_quarter','payment_amount', 'NUMERIC','line_quarter'), + ('q','pay_ho', 'budget_line_quarter','payment_amount_ho', 'NUMERIC','line_quarter'), + ('q','pay_rf', 'budget_line_quarter','payment_amount_rf', 'NUMERIC','line_quarter'), + ('q','pay_comment', 'budget_line_quarter','payment_comment', 'TEXT', 'line_quarter'), + ('q','pay_act', 'budget_line_quarter','payment_act', 'TEXT', 'line_quarter'), + ('q','actual_m1', 'budget_line_quarter','actual_m1', 'NUMERIC','line_quarter'), + ('q','actual_m2', 'budget_line_quarter','actual_m2', 'NUMERIC','line_quarter'), + ('q','actual_m3', 'budget_line_quarter','actual_m3', 'NUMERIC','line_quarter'), + ('q','actual_spod', 'budget_line_quarter','actual_spod', 'NUMERIC','line_quarter'), + ('q','transfer_q2', 'budget_line_quarter','transfer_to_q2', 'NUMERIC','line_quarter'), + ('q','transfer_q3', 'budget_line_quarter','transfer_to_q3', 'NUMERIC','line_quarter'), + ('q','transfer_q4', 'budget_line_quarter','transfer_to_q4', 'NUMERIC','line_quarter'), + ('q','transfer_econ', 'budget_line_quarter','transfer_to_economy', 'NUMERIC','line_quarter'), + ('q','transfer_q2_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q2_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q2_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_q3_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q3_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q3_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_q4_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q4_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q4_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_next_comment', 'budget_line_quarter','transfer_next_comment', 'TEXT', 'line_quarter'), + ('q','transfer_far_comment', 'budget_line_quarter','transfer_far_comment', 'TEXT', 'line_quarter'), + ('q','rev_eco', 'budget_line_quarter','plan_revision_eco_change','NUMERIC','line_quarter'), + ('q','rev_item', 'budget_line_quarter','plan_revision_item_adj', 'NUMERIC','line_quarter'), + ('q','rev_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'), + ('q','rev_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'), + ('q','rev_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'), + ('q','revision_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'), + ('q','revision_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'), + ('q','revision_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'), + ('q','booking_amount', 'budget_line_quarter','booking_amount', 'NUMERIC','line_quarter') + ) AS m(scope, field, target_table, target_col, target_type, key_kind) + WHERE m.scope = (CASE WHEN v_q IS NOT NULL THEN 'q' ELSE v_scope END) + AND m.field = v_field; + + IF v_target_table IS NULL THEN + IF v_field IN ('corrected_plan','new_plan','actual_quarter','booking', + 'rem_booking','rem_actual','residual_after_booking', + 'residual_after_actual','economy','total','year') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + END IF; + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + IF v_target_type = 'NUMERIC' AND p_value = '""'::jsonb THEN + p_value := '"0"'::jsonb; + END IF; + IF v_key_kind = 'line' THEN + IF v_target_type = 'DATE' THEN + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, %2$I) VALUES ($1, to_date($2->>0, ''DD.MM.YYYY'')) ' + || 'ON CONFLICT (line_id) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col + ); + ELSE + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, %2$I) VALUES ($1, ($2 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + END IF; + EXECUTE v_sql USING p_line_id, p_value; + + 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', + v_target_table, v_target_col + ); + + ELSE + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, quarter, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id, quarter) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + 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'')) ' + || 'ON CONFLICT (line_id, actor) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + ELSE + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, actor, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id, actor) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + END IF; + + EXECUTE v_sql USING p_line_id, v_actor, p_value; + + ELSE + RAISE EXCEPTION 'internal: unknown key_kind %', v_key_kind; + END IF; + + UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id; +END; +$function$; + + +-- 4. v3._upsert_form2_ahr_detail_sync_values: добавить actual_spod (25-й +-- параметр). DROP обязателен — перегрузка с одним лишним DEFAULT-параметром +-- иначе даёт неоднозначность вызовов (24 позиционных аргумента подходят и +-- под старую, и под новую сигнатуру). +DROP FUNCTION v3._upsert_form2_ahr_detail_sync_values( + integer, integer, character varying, integer, character varying, character varying, integer, integer, + numeric, numeric, numeric, numeric, + numeric, numeric, numeric, numeric, numeric, numeric, numeric, numeric, numeric, numeric, numeric, numeric +); + +CREATE OR REPLACE FUNCTION v3._upsert_form2_ahr_detail_sync_values( + p_form_id integer, + p_expense_item_id integer, + p_source_sheet character varying, + p_source_id integer, + p_name character varying DEFAULT NULL::character varying, + p_internal_order character varying DEFAULT NULL::character varying, + p_vsp_id integer DEFAULT NULL::integer, + p_existing_line_id integer DEFAULT NULL::integer, + p_plan_q1 numeric DEFAULT 0, + p_plan_q2 numeric DEFAULT 0, + p_plan_q3 numeric DEFAULT 0, + p_plan_q4 numeric DEFAULT 0, + p_actual_jan numeric DEFAULT 0, + p_actual_feb numeric DEFAULT 0, + p_actual_mar numeric DEFAULT 0, + p_actual_apr numeric DEFAULT 0, + p_actual_may numeric DEFAULT 0, + p_actual_jun numeric DEFAULT 0, + p_actual_jul numeric DEFAULT 0, + p_actual_aug numeric DEFAULT 0, + p_actual_sep numeric DEFAULT 0, + p_actual_oct numeric DEFAULT 0, + p_actual_nov numeric DEFAULT 0, + p_actual_dec numeric DEFAULT 0, + p_actual_spod numeric DEFAULT 0 +) +RETURNS void +LANGUAGE plpgsql +AS $function$ +DECLARE + v_line_id integer; +BEGIN + v_line_id := v3._ensure_form2_ahr_detail_sync_line( + p_form_id, + p_expense_item_id, + p_source_sheet, + p_source_id, + p_name, + p_internal_order, + p_vsp_id, + p_existing_line_id + ); + + INSERT INTO v3.plan (line_id, plan_q1, plan_q2, plan_q3, plan_q4) + VALUES ( + v_line_id, + COALESCE(p_plan_q1, 0), + COALESCE(p_plan_q2, 0), + COALESCE(p_plan_q3, 0), + COALESCE(p_plan_q4, 0) + ) + ON CONFLICT (line_id) DO UPDATE SET + plan_q1 = EXCLUDED.plan_q1, + plan_q2 = EXCLUDED.plan_q2, + plan_q3 = EXCLUDED.plan_q3, + plan_q4 = EXCLUDED.plan_q4; + + INSERT INTO v3.budget_line_quarter (line_id, quarter, actual_m1, actual_m2, actual_m3, actual_spod) + VALUES + (v_line_id, 1, COALESCE(p_actual_jan, 0), COALESCE(p_actual_feb, 0), COALESCE(p_actual_mar, 0), NULL), + (v_line_id, 2, COALESCE(p_actual_apr, 0), COALESCE(p_actual_may, 0), COALESCE(p_actual_jun, 0), NULL), + (v_line_id, 3, COALESCE(p_actual_jul, 0), COALESCE(p_actual_aug, 0), COALESCE(p_actual_sep, 0), NULL), + (v_line_id, 4, COALESCE(p_actual_oct, 0), COALESCE(p_actual_nov, 0), COALESCE(p_actual_dec, 0), COALESCE(p_actual_spod, 0)) + ON CONFLICT (line_id, quarter) DO UPDATE SET + actual_m1 = EXCLUDED.actual_m1, + actual_m2 = EXCLUDED.actual_m2, + actual_m3 = EXCLUDED.actual_m3, + actual_spod = EXCLUDED.actual_spod; +END; +$function$; + + +-- 5. v3.sync_form2_ahr_satellite_lines: передать actual_spod из rd/ud/sd. +CREATE OR REPLACE FUNCTION v3.sync_form2_ahr_satellite_lines(p_form_id integer) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_form_type varchar; + v_line_id integer; + v_rec record; +BEGIN + SELECT bf.form_type_code + INTO v_form_type + FROM v3.budget_form bf + WHERE bf.id = p_form_id; + + IF v_form_type IS DISTINCT FROM 'FORM_2' THEN + RETURN; + END IF; + + + + FOR v_rec IN + WITH limit_amounts AS ( + SELECT + ei.id AS expense_item_id, + regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')::varchar AS line_name, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q1) AS plan_q1, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q2) AS plan_q2, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q3) AS plan_q3, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q4) AS plan_q4 + FROM v3.limit_template lt + LEFT JOIN v3.form_limit fl + ON fl.template_id = lt.id + AND fl.budget_form_id = p_form_id + JOIN v3.expense_item ei + ON ei.sheet = 'AHR' + AND ei.direction = 'Support' + AND ( + (ei.depth = 3 AND ei.item_id = lt.expense_item_code) + OR ei.num_group_id = lt.expense_item_code + ) + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = ei.id + AND eft.form_type_code = 'FORM_2' + WHERE lt.row_type = 'LEAF' + ) + SELECT * FROM limit_amounts + LOOP + PERFORM v3._upsert_form2_ahr_limit_sync_values( + p_form_id, v_rec.expense_item_id, v_rec.line_name, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4 + ); + END LOOP; + + + FOR v_line_id IN + SELECT m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + JOIN v3.budget_line bl ON bl.id = m.target_line_id + WHERE m.source_sheet = 'AHR_RENT' + AND bl.budget_form_id = p_form_id + AND NOT EXISTS (SELECT 1 FROM v3.rent_detail rd WHERE rd.id = m.source_id) + LOOP + DELETE FROM v3.plan WHERE line_id = v_line_id; + DELETE FROM v3.budget_line_quarter WHERE line_id = v_line_id; + DELETE FROM v3.budget_line WHERE id = v_line_id; + END LOOP; + + FOR v_rec IN + SELECT + rd.id AS source_id, + rd.line_id AS existing_line_id, + rd.vsp_id, + COALESCE(v.format, 'АХР Аренда #' || rd.id)::varchar AS line_name, + rd.contract_number::varchar AS internal_order, + rd.plan_q1, rd.plan_q2, rd.plan_q3, rd.plan_q4, + rd.actual_jan, rd.actual_feb, rd.actual_mar, + rd.actual_apr, rd.actual_may, rd.actual_jun, + rd.actual_jul, rd.actual_aug, rd.actual_sep, + rd.actual_oct, rd.actual_nov, rd.actual_dec, rd.actual_spod + FROM v3.rent_detail rd + JOIN v3.budget_line bl ON bl.id = rd.line_id + LEFT JOIN v3.vsp v ON v.id = rd.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY rd.id + LOOP + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 314, 'AHR_RENT', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.rent_detail rd + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE rd.id = v_rec.source_id + AND m.source_sheet = 'AHR_RENT' + AND m.source_id = rd.id; + END LOOP; + + + FOR v_line_id IN + SELECT m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + JOIN v3.budget_line bl ON bl.id = m.target_line_id + WHERE m.source_sheet = 'AHR_UTILITY' + AND bl.budget_form_id = p_form_id + AND NOT EXISTS (SELECT 1 FROM v3.utility_detail ud WHERE ud.id = m.source_id) + LOOP + DELETE FROM v3.plan WHERE line_id = v_line_id; + DELETE FROM v3.budget_line_quarter WHERE line_id = v_line_id; + DELETE FROM v3.budget_line WHERE id = v_line_id; + END LOOP; + + FOR v_rec IN + SELECT + ud.id AS source_id, + ud.line_id AS existing_line_id, + ud.vsp_id, + COALESCE(v.format, 'АХР Коммунальные услуги #' || ud.id)::varchar AS line_name, + ud.contract_number::varchar AS internal_order, + ud.plan_q1, ud.plan_q2, ud.plan_q3, ud.plan_q4, + ud.actual_jan, ud.actual_feb, ud.actual_mar, + ud.actual_apr, ud.actual_may, ud.actual_jun, + ud.actual_jul, ud.actual_aug, ud.actual_sep, + ud.actual_oct, ud.actual_nov, ud.actual_dec, ud.actual_spod + FROM v3.utility_detail ud + JOIN v3.budget_line bl ON bl.id = ud.line_id + LEFT JOIN v3.vsp v ON v.id = ud.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY ud.id + LOOP + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 24, 'AHR_UTILITY', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.utility_detail ud + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE ud.id = v_rec.source_id + AND m.source_sheet = 'AHR_UTILITY' + AND m.source_id = ud.id; + END LOOP; + + + FOR v_line_id IN + SELECT m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + JOIN v3.budget_line bl ON bl.id = m.target_line_id + WHERE m.source_sheet = 'AHR_SECURITY' + AND bl.budget_form_id = p_form_id + AND NOT EXISTS (SELECT 1 FROM v3.security_detail sd WHERE sd.id = m.source_id) + LOOP + DELETE FROM v3.plan WHERE line_id = v_line_id; + DELETE FROM v3.budget_line_quarter WHERE line_id = v_line_id; + DELETE FROM v3.budget_line WHERE id = v_line_id; + END LOOP; + + FOR v_rec IN + SELECT + sd.id AS source_id, + sd.line_id AS existing_line_id, + sd.vsp_id, + COALESCE(v.format, 'АХР Безопасность #' || sd.id)::varchar AS line_name, + sd.contract_number::varchar AS internal_order, + sd.plan_q1, sd.plan_q2, sd.plan_q3, sd.plan_q4, + sd.actual_jan, sd.actual_feb, sd.actual_mar, + sd.actual_apr, sd.actual_may, sd.actual_jun, + sd.actual_jul, sd.actual_aug, sd.actual_sep, + sd.actual_oct, sd.actual_nov, sd.actual_dec, sd.actual_spod + FROM v3.security_detail sd + JOIN v3.budget_line bl ON bl.id = sd.line_id + LEFT JOIN v3.vsp v ON v.id = sd.vsp_id + WHERE bl.budget_form_id = p_form_id + ORDER BY sd.id + LOOP + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 779, 'AHR_SECURITY', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.security_detail sd + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE sd.id = v_rec.source_id + AND m.source_sheet = 'AHR_SECURITY' + AND m.source_id = sd.id; + END LOOP; +END; +$function$; + + +-- 6. v3.sync_form2_ahr_satellite_line: тот же набор правок для синка одной строки. +CREATE OR REPLACE FUNCTION v3.sync_form2_ahr_satellite_line(p_form_id integer, p_sheet character varying, p_source_id integer) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_rec record; +BEGIN + IF p_sheet = 'AHR_LIMIT' THEN + SELECT + ei.id AS expense_item_id, + regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')::varchar AS line_name, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q1) AS plan_q1, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q2) AS plan_q2, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q3) AS plan_q3, + v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q4) AS plan_q4 + INTO v_rec + FROM v3.limit_template lt + LEFT JOIN v3.form_limit fl + ON fl.template_id = lt.id + AND fl.budget_form_id = p_form_id + JOIN v3.expense_item ei + ON ei.sheet = 'AHR' + AND ei.direction = 'Support' + AND ( + (ei.depth = 3 AND ei.item_id = lt.expense_item_code) + OR ei.num_group_id = lt.expense_item_code + ) + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = ei.id + AND eft.form_type_code = 'FORM_2' + WHERE lt.id = p_source_id + AND lt.row_type = 'LEAF'; + + IF v_rec.expense_item_id IS NOT NULL THEN + PERFORM v3._upsert_form2_ahr_limit_sync_values( + p_form_id, v_rec.expense_item_id, v_rec.line_name, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4 + ); + END IF; + RETURN; + END IF; + + IF p_sheet = 'AHR_RENT' THEN + SELECT + rd.id AS source_id, + rd.line_id AS existing_line_id, + rd.vsp_id, + COALESCE(v.format, 'АХР Аренда #' || rd.id)::varchar AS line_name, + rd.contract_number::varchar AS internal_order, + rd.plan_q1, rd.plan_q2, rd.plan_q3, rd.plan_q4, + rd.actual_jan, rd.actual_feb, rd.actual_mar, + rd.actual_apr, rd.actual_may, rd.actual_jun, + rd.actual_jul, rd.actual_aug, rd.actual_sep, + rd.actual_oct, rd.actual_nov, rd.actual_dec, rd.actual_spod + INTO v_rec + FROM v3.rent_detail rd + JOIN v3.budget_line bl ON bl.id = rd.line_id + LEFT JOIN v3.vsp v ON v.id = rd.vsp_id + WHERE rd.id = p_source_id + AND bl.budget_form_id = p_form_id; + + IF v_rec.source_id IS NOT NULL THEN + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 314, 'AHR_RENT', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.rent_detail rd + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE rd.id = v_rec.source_id + AND m.source_sheet = 'AHR_RENT' + AND m.source_id = rd.id; + END IF; + RETURN; + END IF; + + IF p_sheet = 'AHR_UTILITY' THEN + SELECT + ud.id AS source_id, + ud.line_id AS existing_line_id, + ud.vsp_id, + COALESCE(v.format, 'АХР Коммунальные услуги #' || ud.id)::varchar AS line_name, + ud.contract_number::varchar AS internal_order, + ud.plan_q1, ud.plan_q2, ud.plan_q3, ud.plan_q4, + ud.actual_jan, ud.actual_feb, ud.actual_mar, + ud.actual_apr, ud.actual_may, ud.actual_jun, + ud.actual_jul, ud.actual_aug, ud.actual_sep, + ud.actual_oct, ud.actual_nov, ud.actual_dec, ud.actual_spod + INTO v_rec + FROM v3.utility_detail ud + JOIN v3.budget_line bl ON bl.id = ud.line_id + LEFT JOIN v3.vsp v ON v.id = ud.vsp_id + WHERE ud.id = p_source_id + AND bl.budget_form_id = p_form_id; + + IF v_rec.source_id IS NOT NULL THEN + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 24, 'AHR_UTILITY', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.utility_detail ud + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE ud.id = v_rec.source_id + AND m.source_sheet = 'AHR_UTILITY' + AND m.source_id = ud.id; + END IF; + RETURN; + END IF; + + IF p_sheet = 'AHR_SECURITY' THEN + SELECT + sd.id AS source_id, + sd.line_id AS existing_line_id, + sd.vsp_id, + COALESCE(v.format, 'АХР Безопасность #' || sd.id)::varchar AS line_name, + sd.contract_number::varchar AS internal_order, + sd.plan_q1, sd.plan_q2, sd.plan_q3, sd.plan_q4, + sd.actual_jan, sd.actual_feb, sd.actual_mar, + sd.actual_apr, sd.actual_may, sd.actual_jun, + sd.actual_jul, sd.actual_aug, sd.actual_sep, + sd.actual_oct, sd.actual_nov, sd.actual_dec, sd.actual_spod + INTO v_rec + FROM v3.security_detail sd + JOIN v3.budget_line bl ON bl.id = sd.line_id + LEFT JOIN v3.vsp v ON v.id = sd.vsp_id + WHERE sd.id = p_source_id + AND bl.budget_form_id = p_form_id; + + IF v_rec.source_id IS NOT NULL THEN + PERFORM v3._upsert_form2_ahr_detail_sync_values( + p_form_id, 779, 'AHR_SECURITY', v_rec.source_id, + v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id, + v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4, + v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar, + v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun, + v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep, + v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod + ); + + UPDATE v3.security_detail sd + SET line_id = m.target_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE sd.id = v_rec.source_id + AND m.source_sheet = 'AHR_SECURITY' + AND m.source_id = sd.id; + END IF; + END IF; +END; +$function$; + + +-- 7. v3.v_svod2_go: строка аренды считается заполненной и если заполнен СПОД. +-- AURORA-1887 (одна сводная строка на статью в сателлитах АХР) и AURORA-1904 +-- (раскладка выгрузки с колонкой СПОД у сателлитов) на момент этой миграции +-- не влиты в test (ветки отсутствуют/не смержены — см. Rulings в PR), поэтому +-- SUM(actual_spod) в сводном агрегате и колонка СПОД в выгрузке не добавляются. +CREATE OR REPLACE FUNCTION v3.v_svod2_go( + p_year integer, + p_org_unit_ids integer[] DEFAULT NULL, + p_offset integer DEFAULT NULL, + p_limit integer DEFAULT NULL, + p_sort_by varchar DEFAULT 'org_name', + p_sort_direction varchar DEFAULT 'asc' +) +RETURNS TABLE( + source_form varchar, source_id integer, source_org_unit_id integer, + org_name varchar, smeta_type varchar, + source_line_id integer, sort_order bigint, data jsonb +) +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +AS $function$ +WITH empty_sections AS MATERIALIZED ( + SELECT + (to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('plan_year', NULL) AS plan, + (to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb)) + - 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration, + (to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id') + || jsonb_build_object('amount_year', NULL) AS reserve, + jsonb_build_object( + 'approved_q1', NULL, 'approved_q2', NULL, + 'approved_q3', NULL, 'approved_q4', NULL, + 'approved_year', NULL + ) AS approved, + to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, + to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation, + to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial, + to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk, + to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail, + to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking, + to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb)) + - 'line_id' - 'quarter' AS quarter +), eligible_lines AS ( + SELECT DISTINCT + bl.id AS line_id, + bf.form_type_code, + bf.id AS form_id, + bf.org_unit_id, + ou.title AS org_name, + ei.section_code, + ei.item_id, + CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВ' + WHEN 'OPER' THEN 'Операц' END::varchar AS smeta_type + FROM v3.budget_form bf + JOIN v3.budget_line bl ON bl.budget_form_id = bf.id + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + LEFT JOIN v3.plan presence_plan ON presence_plan.line_id = bl.id + LEFT JOIN v3.reserve presence_reserve ON presence_reserve.line_id = bl.id + LEFT JOIN v3.contract_summary presence_contract_summary ON presence_contract_summary.line_id = bl.id + LEFT JOIN v3.allocation presence_allocation ON presence_allocation.line_id = bl.id + LEFT JOIN v3.collegial_approval presence_collegial_approval ON presence_collegial_approval.line_id = bl.id + LEFT JOIN v3.ckk presence_ckk ON presence_ckk.line_id = bl.id + LEFT JOIN v3.contract_detail presence_contract_detail ON presence_contract_detail.line_id = bl.id + LEFT JOIN v3.sequestration presence_sequestration ON presence_sequestration.line_id = bl.id + LEFT JOIN v3.booking presence_booking ON presence_booking.line_id = bl.id + LEFT JOIN v3.budget_line_quarter presence_budget_line_quarter ON presence_budget_line_quarter.line_id = bl.id + LEFT JOIN v3.rent_detail presence_rent_detail ON presence_rent_detail.line_id = bl.id + LEFT JOIN v3.security_detail presence_security_detail ON presence_security_detail.line_id = bl.id + LEFT JOIN v3.utility_detail presence_utility_detail ON presence_utility_detail.line_id = bl.id + WHERE bf.year = p_year + AND bf.form_type_code IN ('FORM_1', 'FORM_4') + AND ei.sheet IN ('AHR', 'CAP', 'OPER') + AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 + OR bf.org_unit_id = ANY(p_org_unit_ids)) + AND ( + ( + presence_plan.plan_q1 <> 0 + OR presence_plan.plan_q2 <> 0 + OR presence_plan.plan_q3 <> 0 + OR presence_plan.plan_q4 <> 0 + OR presence_plan.comment ~ '[^[:space:]]' + ) + OR presence_reserve.line_id IS NOT NULL + OR presence_contract_summary.line_id IS NOT NULL + OR presence_allocation.line_id IS NOT NULL + OR presence_collegial_approval.line_id IS NOT NULL + OR presence_ckk.line_id IS NOT NULL + OR presence_contract_detail.line_id IS NOT NULL + OR presence_sequestration.line_id IS NOT NULL + OR presence_booking.line_id IS NOT NULL + OR ( + presence_budget_line_quarter.adj_current <> 0 + OR presence_budget_line_quarter.adj_ssp <> 0 + OR presence_budget_line_quarter.adj_rf <> 0 + OR presence_budget_line_quarter.adj_reserve <> 0 + OR presence_budget_line_quarter.adj_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.target_change <> 0 + OR presence_budget_line_quarter.base_plan_correction <> 0 + OR presence_budget_line_quarter.base_plan_correction_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.payment_date IS NOT NULL + OR presence_budget_line_quarter.payment_amount <> 0 + OR presence_budget_line_quarter.payment_amount_ho <> 0 + OR presence_budget_line_quarter.payment_amount_rf <> 0 + OR presence_budget_line_quarter.payment_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.payment_act ~ '[^[:space:]]' + OR presence_budget_line_quarter.actual_m1 <> 0 + OR presence_budget_line_quarter.actual_m2 <> 0 + OR presence_budget_line_quarter.actual_m3 <> 0 + OR presence_budget_line_quarter.actual_spod <> 0 + OR presence_budget_line_quarter.transfer_to_q2 <> 0 + OR presence_budget_line_quarter.transfer_to_q3 <> 0 + OR presence_budget_line_quarter.transfer_to_q4 <> 0 + OR presence_budget_line_quarter.transfer_to_economy <> 0 + OR presence_budget_line_quarter.transfer_delay_acts <> 0 + OR presence_budget_line_quarter.transfer_delay_procurement <> 0 + OR presence_budget_line_quarter.transfer_economy_rf <> 0 + OR presence_budget_line_quarter.transfer_next_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.transfer_far_comment ~ '[^[:space:]]' + OR presence_budget_line_quarter.booking_amount <> 0 + OR presence_budget_line_quarter.plan_revision_eco_change <> 0 + OR presence_budget_line_quarter.plan_revision_item_adj <> 0 + OR presence_budget_line_quarter.plan_revision_increase <> 0 + OR presence_budget_line_quarter.plan_revision_sequester <> 0 + OR presence_budget_line_quarter.plan_revision_comment ~ '[^[:space:]]' + ) + OR ( + presence_rent_detail.contract_number ~ '[^[:space:]]' + OR presence_rent_detail.contract_end_date IS NOT NULL + OR presence_rent_detail.plan_q1 <> 0 + OR presence_rent_detail.plan_q2 <> 0 + OR presence_rent_detail.plan_q3 <> 0 + OR presence_rent_detail.plan_q4 <> 0 + OR presence_rent_detail.actual_jan <> 0 + OR presence_rent_detail.actual_feb <> 0 + OR presence_rent_detail.actual_mar <> 0 + OR presence_rent_detail.actual_apr <> 0 + OR presence_rent_detail.actual_may <> 0 + OR presence_rent_detail.actual_jun <> 0 + OR presence_rent_detail.actual_jul <> 0 + OR presence_rent_detail.actual_aug <> 0 + OR presence_rent_detail.actual_sep <> 0 + OR presence_rent_detail.actual_oct <> 0 + OR presence_rent_detail.actual_nov <> 0 + OR presence_rent_detail.actual_dec <> 0 + OR presence_rent_detail.actual_spod <> 0 + OR presence_rent_detail.comment ~ '[^[:space:]]' + ) + OR presence_security_detail.line_id IS NOT NULL + OR presence_utility_detail.line_id IS NOT NULL + ) +), numbered_lines AS ( + SELECT el.*, + row_number() OVER ( + ORDER BY + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.org_name END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.org_name END DESC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc' + THEN el.item_id END ASC NULLS LAST, + CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc' + THEN el.item_id END DESC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc' + THEN el.section_code END ASC NULLS LAST, + CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc' + THEN el.section_code END DESC NULLS LAST, + el.form_type_code, el.form_id, el.line_id + ) AS sort_order + FROM eligible_lines el +), page AS MATERIALIZED ( + SELECT * FROM numbered_lines + ORDER BY sort_order + LIMIT p_limit OFFSET COALESCE(p_offset, 0) +), booking_data AS ( + SELECT b.line_id, + jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking + FROM page pg + JOIN v3.booking b ON b.line_id = pg.line_id + GROUP BY b.line_id +) +SELECT + pg.form_type_code, + pg.form_id, + pg.org_unit_id, + pg.org_name, + pg.smeta_type, + pg.line_id, + pg.sort_order, + jsonb_build_object( + 'line_id', bl.id, + 'header', jsonb_build_object( + 'section_code', ei.section_code, 'item_id', ei.item_id, + 'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name), + 'expense_item_id', ei.id, 'justification', bl.justification, + 'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id, + 'project_id', bl.project_id, 'direction', bl.direction, + 'smeta_direction', CASE bl.direction + WHEN 'Support' THEN 'Поддержка' + WHEN 'Development' THEN 'Развитие' + ELSE NULL + END, + 'source_form', pg.form_type_code, 'source_id', pg.form_id, + 'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name, + 'smeta_type', pg.smeta_type), + 'plan', COALESCE( + (to_jsonb(pl) - 'line_id') || jsonb_build_object( + 'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)), + e.plan || jsonb_build_object('plan_year', NULL)), + 'sequestration', jsonb_build_object( + 'DFIP', COALESCE( + (to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL)), + 'SSP_GO', COALESCE( + (to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object( + 'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0) + + COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)), + e.sequestration || jsonb_build_object('adj_year', NULL))), + 'reserve', COALESCE( + (to_jsonb(rv) - 'line_id') || jsonb_build_object( + 'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)), + e.reserve || jsonb_build_object('amount_year', NULL)), + 'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL + AND rv.line_id IS NULL + THEN e.approved + ELSE jsonb_build_object( + 'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0) + + COALESCE(rv.amount_q1, 0), + 'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(rv.amount_q2, 0), + 'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0) + + COALESCE(rv.amount_q3, 0), + 'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q4, 0), + 'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0) + + COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0) + + COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0) + + COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0) + + COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0) + + COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END, + 'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary), + 'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation), + 'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial), + 'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk), + 'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail), + 'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)), + 'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q1, 0) > 0 THEN cd.expenses_q1 ELSE ck.expenses_q1 END + ELSE COALESCE(q1.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q1 + WHEN ck.expenses_q1 > 0 THEN ck.expenses_q1 + ELSE cd.expenses_q1 END) END), + 'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q2, 0) > 0 THEN cd.expenses_q2 ELSE ck.expenses_q2 END + ELSE COALESCE(q2.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q2 + WHEN ck.expenses_q2 > 0 THEN ck.expenses_q2 + ELSE cd.expenses_q2 END) END, + 'plan_revision_eco_change', COALESCE(q2.target_change, q2.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q2.base_plan_correction, q2.plan_revision_item_adj)), + 'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q3, 0) > 0 THEN cd.expenses_q3 ELSE ck.expenses_q3 END + ELSE COALESCE(q3.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q3 + WHEN ck.expenses_q3 > 0 THEN ck.expenses_q3 + ELSE cd.expenses_q3 END) END, + 'plan_revision_eco_change', COALESCE(q3.target_change, q3.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q3.base_plan_correction, q3.plan_revision_item_adj)), + 'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.quarter) || jsonb_build_object( + 'booking_amount', CASE WHEN pg.form_type_code = 'FORM_1' + THEN CASE WHEN COALESCE(cd.expenses_q4, 0) > 0 THEN cd.expenses_q4 ELSE ck.expenses_q4 END + ELSE COALESCE(q4.booking_amount, + CASE WHEN ei.sheet = 'AHR' THEN ck.expenses_q4 + WHEN ck.expenses_q4 > 0 THEN ck.expenses_q4 + ELSE cd.expenses_q4 END) END, + 'plan_revision_eco_change', COALESCE(q4.target_change, q4.plan_revision_eco_change), + 'plan_revision_item_adj', COALESCE(q4.base_plan_correction, q4.plan_revision_item_adj)) + ) AS data +FROM page pg +JOIN v3.budget_line bl ON bl.id = pg.line_id +JOIN v3.expense_item ei ON ei.id = bl.expense_item_id +LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id +CROSS JOIN empty_sections e +LEFT JOIN v3.plan pl ON pl.line_id = bl.id +LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP' +LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO' +LEFT JOIN v3.reserve rv ON rv.line_id = bl.id +LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id +LEFT JOIN v3.allocation al ON al.line_id = bl.id +LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id +LEFT JOIN v3.ckk ck ON ck.line_id = bl.id +LEFT JOIN booking_data bk ON bk.line_id = bl.id +LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1 +LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2 +LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3 +LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4 +ORDER BY pg.sort_order; +$function$; + + +-- 8. Data-миграция этапов: там, где уже разрешено fact_q4.dec на сателлитах +-- АХР, разрешить и fact_q4.spod (идемпотентно — повторный прогон не дублирует). +UPDATE v3.phase_template + SET column_keys = array_append(column_keys, 'fact_q4.spod') + WHERE sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + AND 'fact_q4.dec' = ANY(column_keys) + AND NOT ('fact_q4.spod' = ANY(column_keys)); + +UPDATE v3.form_phase + SET column_keys = array_append(column_keys, 'fact_q4.spod') + WHERE sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + AND 'fact_q4.dec' = ANY(column_keys) + AND NOT ('fact_q4.spod' = ANY(column_keys)); diff --git a/api/src/db/models/rent_detail.py b/api/src/db/models/rent_detail.py index 9b1fda5..f6dc16e 100644 --- a/api/src/db/models/rent_detail.py +++ b/api/src/db/models/rent_detail.py @@ -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") diff --git a/api/src/db/models/security_detail.py b/api/src/db/models/security_detail.py index 91b4449..17fab0a 100644 --- a/api/src/db/models/security_detail.py +++ b/api/src/db/models/security_detail.py @@ -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") diff --git a/api/src/db/models/utility_detail.py b/api/src/db/models/utility_detail.py index 2b2dcef..610911c 100644 --- a/api/src/db/models/utility_detail.py +++ b/api/src/db/models/utility_detail.py @@ -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") diff --git a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py new file mode 100644 index 0000000..c94d276 --- /dev/null +++ b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py @@ -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 diff --git a/api/tests/unit/test_summary2_related_records_sql.py b/api/tests/unit/test_summary2_related_records_sql.py index 04bfd39..1d22fb5 100644 --- a/api/tests/unit/test_summary2_related_records_sql.py +++ b/api/tests/unit/test_summary2_related_records_sql.py @@ -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): diff --git a/web/src/components/RealtimeTable/constants/FORM_2/AHR_RENT.js b/web/src/components/RealtimeTable/constants/FORM_2/AHR_RENT.js index 185c532..f940c66 100644 --- a/web/src/components/RealtimeTable/constants/FORM_2/AHR_RENT.js +++ b/web/src/components/RealtimeTable/constants/FORM_2/AHR_RENT.js @@ -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', diff --git a/web/src/components/RealtimeTable/constants/FORM_2/AHR_SECURITY.js b/web/src/components/RealtimeTable/constants/FORM_2/AHR_SECURITY.js index 5d8273d..03eebac 100644 --- a/web/src/components/RealtimeTable/constants/FORM_2/AHR_SECURITY.js +++ b/web/src/components/RealtimeTable/constants/FORM_2/AHR_SECURITY.js @@ -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', diff --git a/web/src/components/RealtimeTable/constants/FORM_2/AHR_UTILITY.js b/web/src/components/RealtimeTable/constants/FORM_2/AHR_UTILITY.js index 13a3f5f..cc67523 100644 --- a/web/src/components/RealtimeTable/constants/FORM_2/AHR_UTILITY.js +++ b/web/src/components/RealtimeTable/constants/FORM_2/AHR_UTILITY.js @@ -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', diff --git a/web/src/components/RealtimeTable/utils/form2AhrSatelliteSpod.test.js b/web/src/components/RealtimeTable/utils/form2AhrSatelliteSpod.test.js new file mode 100644 index 0000000..854201f --- /dev/null +++ b/web/src/components/RealtimeTable/utils/form2AhrSatelliteSpod.test.js @@ -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', + ); + }); +} -- 2.47.2 From a5ffd476a6eac83b6cfac29372797946d6fb2b1d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 00:41:10 +0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=A1=D0=9F=D0=9E=D0=94=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=20Postgres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогнал api/tests/integration/test_form2_ahr_satellite_spod_integration.py против живого Postgres (perf/perf/perf на localhost:55432, БД dfip_1906, alembic upgrade head + фикстура) — нашёл и поправил два бага самого теста (фичу/миграцию не трогал): - _find_row не учитывал служебную META-строку (editable/role/user_id без id/header), которую v3.v_form_view добавляет первой в любой лист — KeyError: 'id' на первой же выборке. Теперь ищет только среди INPUT-строк. - test_satellite_spod_phase_gates_non_admin_user делал один тест из "запрет без fact_q4.spod → добавили ключ → разрешено": неадминский 403/400 от isp1 внутри теста вызывает db_ref.rollback() в tests/integration/conftest.py (per-request откат на ошибке), который стирает все несохранённые изменения этого же теста — включая только что созданные строку сателлита и form_phase. Разбил на два независимых теста (test_satellite_spod_rejected_before_phase_includes_spod_column / test_satellite_spod_allowed_once_phase_includes_spod_column), в каждом ровно один запрос, которому разрешено провалиться. pytest tests/integration — 157 passed, 1 failed (pre-existing, не связан: test_backend_calls_form3_add_project_and_add_line_functions, FORM_3/add_project, 400 на /api/v1/projects/.../report/.../line), 4 skipped. Новый файл — 15/15. Co-Authored-By: Claude Sonnet 5 --- ...st_form2_ahr_satellite_spod_integration.py | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py index c94d276..f3d5186 100644 --- a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py @@ -62,7 +62,12 @@ def _get_sheet_rows(client, admin_tokens, form2_id, sheet) -> list[dict]: def _find_row(rows: list[dict], predicate) -> dict: + # v_form_view prepends a META row ({"editable": [...], "role": ..., ...}, + # row_type="META", depth=-1) with no "id"/"header" — only INPUT rows carry + # satellite/AHR line data. for row in rows: + if row.get("row_type") != "INPUT": + continue if predicate(row["data"]): return row raise AssertionError("строка не найдена") @@ -141,14 +146,22 @@ def test_satellite_spod_rejects_other_quarters( @pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"]) -def test_satellite_spod_phase_gates_non_admin_user( +def test_satellite_spod_rejected_before_phase_includes_spod_column( client, admin_tokens, isp_tokens, form2_id, sheet ): - """DFIP-пользователь (isp1): без 'fact_q4.spod' в этапе — отказ, - после появления ключа (как после data-миграции) — успех.""" + """DFIP-пользователь (isp1) с этапом, где column_keys ещё не включает + 'fact_q4.spod' (как до data-миграции): запись отклоняется. + + Note: a failed request (403/400) makes the shared test-session's + `_get_db` override call `db_ref.rollback()`, which would wipe out any + uncommitted setup done earlier in the *same* test (the satellite line, + the phase row) — see conftest.py's `client` fixture. So the "before" + and "after" migration scenarios must be two separate tests, each + ending in exactly one request that is allowed to fail. + """ line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet) - phase_code = f"spod_test_{sheet.lower()}" + phase_code = f"spod_test_before_{sheet.lower()}" create_body = { "budget_form_id": form2_id, "sheet": sheet, @@ -173,9 +186,28 @@ def test_satellite_spod_phase_gates_non_admin_user( ) 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"]}, + +@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"]) +def test_satellite_spod_allowed_once_phase_includes_spod_column( + client, admin_tokens, isp_tokens, form2_id, sheet +): + """DFIP-пользователь (isp1) с этапом, где column_keys уже включает + 'fact_q4.spod' (как после data-миграции из этого PR): запись проходит.""" + line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet) + + phase_code = f"spod_test_after_{sheet.lower()}" + response = client.post( + f"/api/v1/stages/form/{form2_id}", + json={ + "budget_form_id": form2_id, + "sheet": sheet, + "phase_code": phase_code, + "role": "DFIP", + "column_keys": ["fact_q4.dec", "fact_q4.spod"], + "opens_at": "2020-01-01T00:00:00", + "closes_at": "2099-01-01T00:00:00", + "direction": None, + }, headers=_auth_headers(admin_tokens), ) assert response.status_code == 200, response.text @@ -186,9 +218,3 @@ def test_satellite_spod_phase_gates_non_admin_user( 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 -- 2.47.2