From d41668dc27128f749c2aaf7d3ab06112e64df966 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 00:24:56 +0400 Subject: [PATCH 1/6] =?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/6] =?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 From 91cbe99c326d8043af60d46630e9d6f93e7ba1c2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 15:07:26 +0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(form2):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=20=D0=BD=D0=B0=20=D1=81=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B5=20=D0=90=D0=A5=D0=A0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=85=D0=B2=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=80=D1=83=D1=87=D0=BD=D1=83=D1=8E=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.add_budget_line (ветка сателлитов AHR_RENT/AHR_UTILITY/AHR_SECURITY) искала любую существующую строку статьи без ORDER BY и без исключения ручных/уже привязанных строк, и переиспользовала её как заготовку для новой записи. Если у статьи ещё не было строк в v3.form2_ahr_satellite_line_source (типично — первая запись при наличии ручной строки той же статьи), sync переименовывал и перезаписывал план/факт чужой ручной строки, блокируя её для правки. Теперь для каждой новой записи сателлита всегда создаётся отдельная пустая строка-заготовка — sync забирает её как строку именно этой записи. Заодно удалён мёртвый код (_ensure_form2_ahr_sync_line, _upsert_form2_ahr_sync_values), не имевший вызывающих мест. AURORA-1907 (лимитная часть) и AURORA-1887 (статьи 314/24/779 в коде) — отдельные задачи, не затронуты. Closes #265. Co-Authored-By: Claude Sonnet 5 --- .../0051_form2_ahr_satellite_new_line.py | 15 ++ .../sql/0051_form2_ahr_satellite_new_line.sql | 228 ++++++++++++++++++ .../test_form2_ahr_satellite_integration.py | 163 +++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 api/alembic/versions/0051_form2_ahr_satellite_new_line.py create mode 100644 api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql create mode 100644 api/tests/integration/test_form2_ahr_satellite_integration.py diff --git a/api/alembic/versions/0051_form2_ahr_satellite_new_line.py b/api/alembic/versions/0051_form2_ahr_satellite_new_line.py new file mode 100644 index 0000000..610f829 --- /dev/null +++ b/api/alembic/versions/0051_form2_ahr_satellite_new_line.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0051" +down_revision = "0050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0051_form2_ahr_satellite_new_line.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql b/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql new file mode 100644 index 0000000..9ee6b09 --- /dev/null +++ b/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql @@ -0,0 +1,228 @@ +-- AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана) +-- не должно захватывать существующую (в т.ч. ручную) строку статьи 314/24/779. +-- +-- v3.add_budget_line, ветка p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY'): +-- раньше искало ЛЮБУЮ существующую строку статьи (без ORDER BY, без исключения +-- ручных и уже привязанных строк) и переиспользовало её как v_parent_line. +-- Если строка не была зарегистрирована в v3.form2_ahr_satellite_line_source, +-- sync (_ensure_form2_ahr_detail_sync_line, 0031) переименовывал и перезаписывал +-- её план/факт значениями новой записи — в т.ч. ручные строки исполнителя. +-- Теперь всегда создаётся новая пустая строка-заготовка; sync заберёт её себе +-- как строку именно этой записи (её ещё нет в form2_ahr_satellite_line_source). +-- +-- Лимитная часть (_ensure_form2_ahr_limit_sync_line) здесь не трогается — +-- это AURORA-1907. Статьи 314/24/779 не трогаются — это AURORA-1887. + +CREATE OR REPLACE FUNCTION v3.add_budget_line(p_form_id integer, p_expense_item_id integer DEFAULT NULL::integer, p_sheet character varying DEFAULT NULL::character varying, p_item_id character varying DEFAULT NULL::character varying, p_section_code character varying DEFAULT NULL::character varying, p_direction character varying DEFAULT NULL::character varying, 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_project_id integer DEFAULT NULL::integer, p_justification character varying DEFAULT NULL::character varying, p_contract_number character varying DEFAULT NULL::character varying, p_contract_end_date date DEFAULT NULL::date) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_form_type VARCHAR; + v_eid INT; + v_sheet VARCHAR; + v_auto_dir VARCHAR; + v_final_dir VARCHAR; + v_new_id INT; + v_cnt INT; + v_sat_table TEXT; + v_sat_eid INT; + v_parent_line INT; + v_decision JSONB; + v_msg TEXT; + v_user_id INT; + v_line_name VARCHAR; +BEGIN + SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id; + IF v_form_type IS NULL THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + IF v_form_type = 'FORM_3' THEN + RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)'; + END IF; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(p_form_id, p_sheet, p_direction, NULL, v_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: Невозможно вставить строку'; + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: Невозможно вставить строку'; + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: opens %s', v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: closed %s', v_decision->>'closes_at'); + ELSE + v_msg := 'window_closed: no phase'; + END IF; + RAISE EXCEPTION '%', v_msg; + ELSE + RAISE EXCEPTION '%', v_decision->>'code'; + END IF; + 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; + v_sat_eid := CASE p_sheet + WHEN 'AHR_RENT' THEN 314 + WHEN 'AHR_UTILITY' THEN 24 + WHEN 'AHR_SECURITY' THEN 779 + END; + + IF p_vsp_id IS NULL THEN + RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + + -- AURORA-1909: раньше здесь искалась и переиспользовалась любая + -- существующая строка статьи (в т.ч. ручная — см. комментарий в начале + -- файла). Каждая новая запись сателлита получает свою строку-заготовку; + -- sync_form2_ahr_satellite_line(s) заберёт её как строку именно этой + -- записи, т.к. её ещё нет в v3.form2_ahr_satellite_line_source. + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) + VALUES ( + p_form_id, v_sat_eid, NULL, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + + EXECUTE format( + 'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) ' + || 'VALUES ($1, $2, $3, $4) RETURNING id', + v_sat_table + ) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date + INTO v_new_id; + + PERFORM v3.sync_form2_ahr_satellite_line(p_form_id, p_sheet, v_new_id); + + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v + WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id; + RETURN; + END IF; + + IF p_expense_item_id IS NOT NULL THEN + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei WHERE ei.id = p_expense_item_id; + IF v_eid IS NULL THEN + RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.expense_item_form_type + WHERE expense_item_id = v_eid AND form_type_code = v_form_type + ) THEN + RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type; + END IF; + ELSE + IF p_sheet IS NULL OR p_item_id IS NULL THEN + RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)'; + END IF; + + SELECT count(*) INTO v_cnt + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + + IF v_cnt = 0 THEN + RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%', + p_sheet, p_item_id, p_section_code, p_direction, v_form_type; + END IF; + IF v_cnt > 1 THEN + RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую', + v_cnt, p_sheet, p_item_id, p_section_code, p_direction; + END IF; + + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + END IF; + + v_auto_dir := CASE + WHEN v_form_type = 'FORM_2' THEN 'Support' + WHEN v_form_type = 'FORM_4' THEN 'Development' + WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL + ELSE NULL + END; + + IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN + RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet; + END IF; + + IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN + RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)', + p_direction, v_form_type, v_auto_dir; + END IF; + + IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN + RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction; + END IF; + + v_final_dir := COALESCE(p_direction, v_auto_dir); + + IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN + RAISE EXCEPTION 'form4_project id=% не существует', p_project_id; + END IF; + + v_line_name := CASE WHEN v_form_type = 'FORM_4' THEN NULL ELSE '' END; + + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, + vsp_id, project_id, direction, justification + ) VALUES ( + p_form_id, v_eid, v_line_name, p_internal_order, + p_vsp_id, p_project_id, v_final_dir, p_justification + ) RETURNING id INTO v_new_id; + + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', v_new_id, + 'expense_item_id', v_eid, + 'direction', v_final_dir, + 'name', v_line_name, + 'vsp_id', p_vsp_id, + 'project_id', p_project_id + ), + p_form_id + ); + + DECLARE v_ei_path INT[]; + BEGIN + v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]); + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v + WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)) + OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id) + ORDER BY v.sort_order; + END; +END; +$function$; + +-- AURORA-1909: мёртвый код (0031) — _ensure_form2_ahr_sync_line вызывается +-- только из _upsert_form2_ahr_sync_values, а саму _upsert_form2_ahr_sync_values +-- нигде не вызывают (актуальный путь — ...detail_sync_line/...detail_sync_values). +DROP FUNCTION IF EXISTS v3._upsert_form2_ahr_sync_values(INT, INT, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC); +DROP FUNCTION IF EXISTS v3._ensure_form2_ahr_sync_line(INT, INT); diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py new file mode 100644 index 0000000..5a33240 --- /dev/null +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -0,0 +1,163 @@ +"""AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана) +не должно захватывать существующую — в т.ч. ручную — строку статьи 314/24/779. + +До фикса v3.add_budget_line (api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql) +искала ЛЮБУЮ существующую строку статьи на листе-источнике (без ORDER BY, +без исключения ручных и уже привязанных строк) и переиспользовала её как +"заготовку" для новой записи сателлита. Если у статьи ещё не было ни одной +строки в v3.form2_ahr_satellite_line_source (типично — первая запись сателлита +при наличии ручной строки той же статьи), sync (_ensure_form2_ahr_detail_sync_line, +0031) переименовывал и перезаписывал план/факт чужой строки — в т.ч. ручной, +созданной исполнителем на листе AHR. + +Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py +client fixture), поэтому изменения не переживают отдельный тест — на каждый +тест создаётся собственная форма FORM_2 через POST /api/v1/form/. +""" + +import pytest + +# статья листа-источника (AHR) для каждого сателлита — зашита в v3.add_budget_line +# (AURORA-1887, здесь не трогается). +SATELLITE_PARENT_EXPENSE_ITEM = { + "AHR_RENT": 314, + "AHR_UTILITY": 24, + "AHR_SECURITY": 779, +} + + +def _create_form2(client, admin_tokens, auth_headers, year: int) -> int: + response = client.post( + "/api/v1/form/", + json={"year": year, "form_type_code": "FORM_2", "org_unit_ids": [1]}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"][0]["id"] + + +def _add_manual_ahr_line(client, admin_tokens, auth_headers, form_id, expense_item_id) -> int: + response = client.post( + f"/api/v1/form/{form_id}/sheet/AHR/line", + json={"expense_item_id": expense_item_id}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + input_rows = [r for r in response.json()["result"] if r["row_type"] == "INPUT"] + assert len(input_rows) == 1, input_rows + return input_rows[0]["data"]["line_id"] + + +def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): + response = client.patch( + f"/api/v1/form/{form_id}/sheet/AHR/cell", + json={"line_id": line_id, "column": column, "value": value}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + + +def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict: + response = client.post( + f"/api/v1/form/{form_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, rows + return rows[0]["data"] + + +def _get_ahr_rows(client, admin_tokens, auth_headers, form_id) -> list[dict]: + response = client.get( + f"/api/v1/form/{form_id}/sheet/AHR", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _find_ahr_row_by_line_id(rows: list[dict], line_id: int) -> dict: + for row in rows: + if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id: + return row + raise AssertionError(f"line_id={line_id} не найден на листе AHR: {rows}") + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_line_add_does_not_capture_existing_manual_line( + client, admin_tokens, auth_headers, sheet +): + """Основной сценарий бага: у статьи уже есть ручная строка (и больше ни + одной), затем на лист-сателлит добавляется запись. Ручная строка не + должна быть переименована/перезаписана/привязана — запись должна + получить свою собственную строку АХР.""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2030) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, expense_item_id + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500 + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "header.name", "моя строка" + ) + + satellite_data = _add_satellite_line( + client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2 + ) + satellite_line_id = satellite_data["line_id"] + + # запись сателлита получила свою строку, а не строку ручной записи + assert satellite_line_id != manual_line_id + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + # ручная строка не тронута: тот же line_id, то же имя, тот же план + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["name"] == "моя строка" + assert manual_row["data"]["plan"]["q1"] == 500 + + # у записи сателлита — отдельная строка на листе AHR (строка синка) + satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id) + assert satellite_row["data"]["line_id"] == satellite_line_id + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_manual_line_stays_deletable_after_satellite_add( + client, admin_tokens, auth_headers, sheet +): + """Ручная строка остаётся удаляемой (не заблокирована + is_form2_ahr_satellite_line, т.к. в form2_ahr_satellite_line_source её нет).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2031) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, expense_item_id + ) + _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) + + response = client.delete( + f"/api/v1/form/{form_id}/sheet/AHR/line/{manual_line_id}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_two_satellite_records_on_empty_item_get_two_lines( + client, admin_tokens, auth_headers, sheet +): + """Две записи подряд на пустой статье (без ручной строки) — уже до + AURORA-1887 (сводная строка источника) должны получать по своей строке + АХР каждая; ни одна из них не должна быть ручной (на статье ручных + строк нет вовсе).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2032) + + first = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + second = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) + + assert first["line_id"] != second["line_id"] -- 2.47.2 From ab765f028d73471690cbd46f430047c516530ded Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 15:35:23 +0400 Subject: [PATCH 4/6] =?UTF-8?q?fix(form2):=20=D0=BB=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D0=90=D0=A5=D0=A0=20=E2=80=94=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D1=8B=20=D0=B4=D0=B5=D0=BB=D1=8F=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=204,=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=9D=D0=94=D0=A1,=20=D1=82=D1=8B=D1=81.?= =?UTF-8?q?=20=D1=80=D1=83=D0=B1.=20=D0=B2=20=D0=90=D0=A5=D0=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AURORA-1897: лист «АХР лимиты» формы 2 дублировал годовой норматив ДМС/оргтехники в каждом квартале вместо деления на 4, считал сумму от лимита с НДС (смета ведётся без НДС) и переносил рубли в план АХР, где всё остальное в тыс. руб. Миграция 0052_form2_ahr_limit_period_vat_units: - v3.limit_template.period (YEAR/QUARTER/MONTH) + точечные правки справочника (id 24/25 ДМС и id 13-15 принтеры/МФУ/копиры — YEAR; id 7/8 SIM-карты — заполнен limit_without_vat; id 21 — unit по аналогии с бумагой). - v3._form2_ahr_limit_amount(period, limit, qty) — рубли без НДС, без проверки по названию; старая 4-арг. сигнатура удалена. - v3.v_form2_view (ветка AHR_LIMIT), v3.sync_form2_ahr_satellite_lines(_line) пересозданы: amount/plan считаются от limit_without_vat, план АХР делится на 1000, добавлены amount_year и строка «Итого» (id=0, row_type ROOT). - v3._limit_template_path_set дописывает id=0 в path-set, чтобы «Итого» попадала в diff PATCH-ответа. - Пересинхронизированы все существующие формы 2. Ветки аренды/коммуналки/охраны и add_budget_line (AURORA-1887/1909) не тронуты. Тесты: api/tests/integration/test_form2_ahr_limit.py (7 тестов, числа из тикета: id24 qty10→69645/69.645, id3→2000/2.0, id7→7500/7.5, id13→6471.875/ 6.471875, id17→7500/7.5, «Итого», регрессия АХР_RENT без деления). Co-Authored-By: Claude Sonnet 5 --- .../0052_form2_ahr_limit_period_vat_units.py | 15 + .../0052_form2_ahr_limit_period_vat_units.sql | 914 ++++++++++++++++++ api/src/db/models/limit_template.py | 7 + api/tests/integration/test_form2_ahr_limit.py | 212 ++++ 4 files changed, 1148 insertions(+) create mode 100644 api/alembic/versions/0052_form2_ahr_limit_period_vat_units.py create mode 100644 api/alembic/versions/sql/0052_form2_ahr_limit_period_vat_units.sql create mode 100644 api/tests/integration/test_form2_ahr_limit.py diff --git a/api/alembic/versions/0052_form2_ahr_limit_period_vat_units.py b/api/alembic/versions/0052_form2_ahr_limit_period_vat_units.py new file mode 100644 index 0000000..e1187ab --- /dev/null +++ b/api/alembic/versions/0052_form2_ahr_limit_period_vat_units.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0052" +down_revision = "0051" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0052_form2_ahr_limit_period_vat_units.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0052_form2_ahr_limit_period_vat_units.sql b/api/alembic/versions/sql/0052_form2_ahr_limit_period_vat_units.sql new file mode 100644 index 0000000..c2423d4 --- /dev/null +++ b/api/alembic/versions/sql/0052_form2_ahr_limit_period_vat_units.sql @@ -0,0 +1,914 @@ +-- AURORA-1897: лист «АХР лимиты» формы 2. +-- 1) годовые нормативы (ДМС/оргтехника) делятся на 4 квартала, а не +-- дублируются в каждом квартале; +-- 2) сумма на листе лимитов и в плане АХР считается от лимита БЕЗ НДС; +-- 3) план лимитных строк в АХР переводится в тыс. руб. (как аренда/ +-- коммуналка/охрана/Смета), а не остаётся в рублях. +-- +-- CREATE OR REPLACE для v3.v_form2_view, v3.sync_form2_ahr_satellite_lines и +-- v3.sync_form2_ahr_satellite_line ниже воспроизводят тело функций, актуальное +-- на голове миграций этой ветки (0051): сами функции последний раз целиком +-- переопределены в 0050_form2_ahr_spod_column.sql (ради колонки СПОД), +-- 0051_form2_ahr_satellite_new_line.sql их не трогает (проверено grep — +-- он редактирует только v3.add_budget_line). Правится только ветка +-- AHR_LIMIT; ветки AHR/CAP/OPER/AHR_RENT/AHR_UTILITY/AHR_SECURITY/OTCH9F/SMETA +-- и переносы аренды/коммуналки/охраны копируются из 0050 без изменений. + + +-- 1. Справочник: period — YEAR / QUARTER / MONTH, по умолчанию QUARTER. +ALTER TABLE v3.limit_template + ADD COLUMN IF NOT EXISTS period varchar NOT NULL DEFAULT 'QUARTER'; + +ALTER TABLE v3.limit_template + ADD CONSTRAINT ck_v3_limit_template_period CHECK (period IN ('YEAR','QUARTER','MONTH')); + +-- Backfill по тому же правилу, что раньше зашивал фактор прямо в формулу. +UPDATE v3.limit_template SET period = 'YEAR' WHERE unit LIKE '%/год'; +UPDATE v3.limit_template SET period = 'MONTH' WHERE unit LIKE '%/месяц'; + + +-- 2. Точечные правки справочника (см. разбор в тикете AURORA-1897). +DO $do$ +BEGIN + UPDATE v3.limit_template + SET unit = '1 чел/год', period = 'YEAR' + WHERE id = 24 AND name = '- стандарт РФ'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=24 (- стандарт РФ) не найден'; + END IF; + + UPDATE v3.limit_template + SET unit = '1 чел/год', period = 'YEAR' + WHERE id = 25 AND name = '- стандарт 1 РФ'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=25 (- стандарт 1 РФ) не найден'; + END IF; + + UPDATE v3.limit_template + SET unit = '1 шт/год', period = 'YEAR', + limit_with_vat = 6213, limit_without_vat = 5177.5 + WHERE id = 13 AND name = '- принтеры'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=13 (- принтеры) не найден'; + END IF; + + UPDATE v3.limit_template + SET unit = '1 шт/год', period = 'YEAR', + limit_with_vat = 6213, limit_without_vat = 5177.5 + WHERE id = 14 AND name = '- МФУ'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=14 (- МФУ) не найден'; + END IF; + + UPDATE v3.limit_template + SET unit = '1 шт/год', period = 'YEAR', + limit_with_vat = 6213, limit_without_vat = 5177.5 + WHERE id = 15 AND name = '- копировальные аппараты'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=15 (- копировальные аппараты) не найден'; + END IF; + + UPDATE v3.limit_template + SET unit = '1 чел/квартал' + WHERE id = 21 AND name = '- Канцелярские принадлежности'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=21 (- Канцелярские принадлежности) не найден'; + END IF; + + UPDATE v3.limit_template + SET limit_without_vat = 375 + WHERE id = 7 AND name = '- SIM-карта для планшета (легкий фронт)'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=7 (SIM-карта для планшета) не найден'; + END IF; + + UPDATE v3.limit_template + SET limit_without_vat = 375 + WHERE id = 8 AND name = '- SIM-карта для уполномоченного работника'; + IF NOT FOUND THEN + RAISE EXCEPTION 'limit_template.id=8 (SIM-карта для уполномоченного работника) не найден'; + END IF; +END; +$do$; + + +-- 3. Новая формула: рубли без НДС, период вместо regexp по названию unit. +CREATE OR REPLACE FUNCTION v3._form2_ahr_limit_amount( + p_period character varying, + p_limit numeric, + p_qty numeric +) +RETURNS numeric +LANGUAGE sql +IMMUTABLE +AS $function$ + SELECT COALESCE(p_qty, 0) * COALESCE(p_limit, 0) * + CASE p_period + WHEN 'YEAR' THEN 0.25 + WHEN 'MONTH' THEN 3 + ELSE 1 + END; +$function$; + + +-- 4а. v3.v_form2_view: ветка AHR_LIMIT — amount от limit_without_vat, +-- amount_year, period в data, последняя строка «Итого» (row_type ROOT). +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.period, lt.limit_without_vat, fl.qty_q1) AS amount_q1, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) AS amount_q2, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) AS amount_q3, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_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' + ), + leaf_amt_year AS ( + SELECT la.*, + (COALESCE(la.amount_q1,0)+COALESCE(la.amount_q2,0) + +COALESCE(la.amount_q3,0)+COALESCE(la.amount_q4,0)) AS amount_year + FROM leaf_amt la + ), + 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, + SUM(la.amount_year) AS amount_year + FROM tree t + JOIN leaf_amt_year la ON la.id = t.desc_id + GROUP BY t.root_id + ), + rows AS ( + SELECT + + CASE lt.row_type WHEN 'SECTION' THEN 'ROOT' + WHEN 'GROUP' THEN 'GROUP' + WHEN 'LEAF' THEN 'INPUT' END::VARCHAR AS row_type, + CASE lt.row_type WHEN 'SECTION' THEN 0 WHEN 'GROUP' THEN 1 ELSE 2 END AS depth, + lt.sort_order::BIGINT AS sort_order, + jsonb_build_object( + 'id', lt.id, + 'section_no', lt.section_no, + 'expense_item_code',lt.expense_item_code, + 'name', lt.name, + 'unit', lt.unit, + 'period', lt.period, + '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, + 'amount_year', a.amount_year, + 'comment', fl.comment + ) AS data + 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 + + UNION ALL + + SELECT + 'ROOT'::VARCHAR AS row_type, + 0 AS depth, + 999999::BIGINT AS sort_order, + jsonb_build_object( + 'id', 0, + 'name', 'Итого', + 'amount_q1', totals.amount_q1, + 'amount_q2', totals.amount_q2, + 'amount_q3', totals.amount_q3, + 'amount_q4', totals.amount_q4, + 'amount_year', totals.amount_year + ) AS data + FROM ( + SELECT + SUM(la.amount_q1) AS amount_q1, + SUM(la.amount_q2) AS amount_q2, + SUM(la.amount_q3) AS amount_q3, + SUM(la.amount_q4) AS amount_q4, + SUM(la.amount_year) AS amount_year + FROM leaf_amt_year la + ) totals + ) + SELECT rows.row_type, rows.depth, rows.sort_order, rows.data + FROM rows + ORDER BY rows.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; + + +-- 4б. v3.sync_form2_ahr_satellite_lines: лимитная ветка — amount от +-- limit_without_vat, деление на 1000 при переносе в план АХР. Ветки +-- AHR_RENT/AHR_UTILITY/AHR_SECURITY не трогаются (AURORA-1887/1909). +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.period, lt.limit_without_vat, fl.qty_q1) / 1000 AS plan_q1, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) / 1000 AS plan_q2, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) / 1000 AS plan_q3, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q4) / 1000 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$; + + +-- 4в. 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.period, lt.limit_without_vat, fl.qty_q1) / 1000 AS plan_q1, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) / 1000 AS plan_q2, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) / 1000 AS plan_q3, + v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q4) / 1000 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$; + + +-- 5. Старая 4-аргументная сигнатура больше не вызывается — удаляем. +DROP FUNCTION v3._form2_ahr_limit_amount(character varying, character varying, numeric, numeric); + + +-- 6. Строка «Итого» (id=0) должна попадать в path-set, чтобы upd_form_cells +-- включал её в diff при любом изменении ячейки на листе AHR_LIMIT. +CREATE OR REPLACE FUNCTION v3._limit_template_path_set(p_ids integer[]) + RETURNS integer[] + LANGUAGE sql + STABLE +AS $function$ + WITH RECURSIVE up AS ( + SELECT id, parent_id FROM v3.limit_template WHERE id = ANY(p_ids) + UNION + SELECT p.id, p.parent_id + FROM v3.limit_template p + JOIN up u ON p.id = u.parent_id + ), + agg AS ( + SELECT array_agg(DISTINCT id) AS ids FROM up + ) + SELECT CASE + WHEN p_ids IS NULL OR array_length(p_ids, 1) IS NULL THEN agg.ids + ELSE COALESCE(agg.ids, ARRAY[]::integer[]) || ARRAY[0] + END + FROM agg; +$function$; + + +-- 7. Пересинхронизировать все существующие формы 2 с новой формулой. +DO $do$ +DECLARE + v_form_id integer; +BEGIN + FOR v_form_id IN + SELECT id + FROM v3.budget_form + WHERE form_type_code = 'FORM_2' + LOOP + PERFORM v3.sync_form2_ahr_satellite_lines(v_form_id); + END LOOP; +END; +$do$; diff --git a/api/src/db/models/limit_template.py b/api/src/db/models/limit_template.py index 99c8006..5307f19 100644 --- a/api/src/db/models/limit_template.py +++ b/api/src/db/models/limit_template.py @@ -13,6 +13,10 @@ class LimitTemplate(Base): "row_type IN ('SECTION','GROUP','LEAF')", name="ck_v3_limit_template_row_type", ), + CheckConstraint( + "period IN ('YEAR','QUARTER','MONTH')", + name="ck_v3_limit_template_period", + ), Index("ix_v3_limit_template_parent", "parent_id"), {"schema": "v3"}, ) @@ -25,6 +29,9 @@ class LimitTemplate(Base): expense_item_code: Mapped[str | None] = mapped_column(String) name: Mapped[str] = mapped_column(String) unit: Mapped[str | None] = mapped_column(String) + # Период норматива: YEAR — делится на 4 квартала, MONTH — умножается на 3, + # QUARTER (по умолчанию) — считается как есть. AURORA-1897. + period: Mapped[str] = mapped_column(String, nullable=False, server_default="QUARTER") limit_with_vat: Mapped[float | None] = mapped_column(Numeric) limit_without_vat: Mapped[float | None] = mapped_column(Numeric) diff --git a/api/tests/integration/test_form2_ahr_limit.py b/api/tests/integration/test_form2_ahr_limit.py new file mode 100644 index 0000000..b261ccd --- /dev/null +++ b/api/tests/integration/test_form2_ahr_limit.py @@ -0,0 +1,212 @@ +"""AURORA-1897: лист «АХР лимиты» формы 2. + +Три правки одной формулой/миграцией (0052_form2_ahr_limit_period_vat_units): + 1) годовые нормативы (ДМС id 23/24/25, оргтехника id 13/14/15) делятся на + 4 квартала (period=YEAR), а не дублируются в каждом квартале; + 2) сумма на листе лимитов и в плане АХР считается от лимита БЕЗ НДС + (limit_without_vat), а не с НДС; + 3) план лимитных строк в АХР переводится в тыс. руб. (как аренда/ + коммуналка/охрана/Смета формы 2), а не остаётся в рублях. + +Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py +client fixture), поэтому изменения не переживают отдельный тест. +""" + +import pytest + +# АХР Аренда синхронизируется в АХР под статьёй с этим expense_item_id +# (v3.sync_form2_ahr_satellite_line(s), api/alembic/versions/sql/ +# 0050_form2_ahr_spod_column.sql) — используется только в регрессионном +# тесте ветки аренды (эта миграция её не трогает). +AHR_RENT_PARENT_EXPENSE_ITEM = 314 + + +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 _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 _patch_cell(client, admin_tokens, form2_id, sheet, line_id, column, value) -> list[dict]: + response = client.patch( + f"/api/v1/form/{form2_id}/sheet/{sheet}/cell", + json={"line_id": line_id, "column": column, "value": value}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _find_by_id(rows: list[dict], target_id: int) -> dict: + # v_form_view может отдавать META-строку ({"editable": [...], ...}) без + # "id" — её пропускаем, т.к. (data->>'id')::INT там NULL. + for row in rows: + data = row.get("data") + if isinstance(data, dict) and data.get("id") == target_id: + return row + raise AssertionError(f"строка с id={target_id} не найдена среди {rows!r}") + + +def _find_ahr_row_by_name(rows: list[dict], name: str) -> dict: + for row in rows: + if row.get("row_type") != "INPUT": + continue + data = row.get("data") or {} + if (data.get("header") or {}).get("name") == name: + return row + raise AssertionError(f"строка АХР с именем {name!r} не найдена среди {rows!r}") + + +def _find_ahr_row_by_expense_item(rows: list[dict], expense_item_id: int) -> dict: + for row in rows: + if row.get("row_type") != "INPUT": + continue + data = row.get("data") or {} + if (data.get("header") or {}).get("expense_item_id") == expense_item_id: + return row + raise AssertionError( + f"строка АХР с expense_item_id={expense_item_id} не найдена среди {rows!r}" + ) + + +def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id): + # id=24 «- стандарт РФ»: ДМС, лимит 27858 руб/год без НДС, period=YEAR — + # 10 * 27858 / 4 = 69645 руб. в заполненный квартал (а не дублирование + # годовой суммы 278580, как было до миграции). + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10) + row = _find_by_id(rows, 24) + assert row["data"]["amount_q1"] == 69645 + assert row["data"]["amount_year"] == 69645 + + ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") + ahr_row = _find_ahr_row_by_name(ahr_rows, "стандарт РФ") + assert ahr_row["data"]["plan"]["q1"] == pytest.approx(69.645) + + +def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id): + # id=3 «- Директор»: 1 чел/квартал, 2400 руб. с НДС / 2000 без НДС — + # сумма должна считаться от лимита БЕЗ НДС. + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1) + row = _find_by_id(rows, 3) + assert row["data"]["amount_q2"] == 2000 + + ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") + ahr_row = _find_ahr_row_by_name(ahr_rows, "Директор") + assert ahr_row["data"]["plan"]["q2"] == pytest.approx(2.0) + + +def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, form2_id): + # id=7 «- SIM-карта для планшета (легкий фронт)»: limit_without_vat был + # NULL до миграции — она заполнила его 375 (450 / 1.2). Сумма не должна + # стать 0 после перехода на расчёт без НДС. + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20) + row = _find_by_id(rows, 7) + assert row["data"]["amount_q1"] == 7500 + + ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") + ahr_row = _find_ahr_row_by_name(ahr_rows, "SIM-карта для планшета (легкий фронт)") + assert ahr_row["data"]["plan"]["q1"] == pytest.approx(7.5) + + +def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_id): + # id=13 «- принтеры»: раньше считалось по захардкоженному имени + # (qty * 0.57 * 10900 / 4), теперь — общей формулой через period=YEAR, + # limit_without_vat=5177.5 (миграция переписала справочник). + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5) + row = _find_by_id(rows, 13) + assert row["data"]["amount_q1"] == pytest.approx(6471.875) + + ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") + ahr_row = _find_ahr_row_by_name(ahr_rows, "принтеры") + assert ahr_row["data"]["plan"]["q1"] == pytest.approx(6.471875) + + +def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, form2_id): + # id=17 «- счетчики/сортировщики банкнот»: квартальный норматив (period + # остаётся QUARTER по умолчанию) считается как раньше — поменялся только + # источник суммы (НДС). + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2) + row = _find_by_id(rows, 17) + assert row["data"]["amount_q3"] == 7500 + + ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") + ahr_row = _find_ahr_row_by_name(ahr_rows, "счетчики/сортировщики банкнот") + assert ahr_row["data"]["plan"]["q3"] == pytest.approx(7.5) + + +def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, form2_id): + patch_rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10) + # Ответ PATCH должен содержать строку «Итого» (data.id = 0) в diff, т.к. + # v3._limit_template_path_set дописывает 0 к path-set любого изменения. + total_in_patch = _find_by_id(patch_rows, 0) + assert total_in_patch["row_type"] == "ROOT" + + full_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR_LIMIT") + leaf_rows = [ + row + for row in full_rows + if row.get("row_type") == "INPUT" and (row.get("data") or {}).get("id") is not None + ] + total_row = next( + row + for row in full_rows + if row.get("row_type") == "ROOT" and (row.get("data") or {}).get("id") == 0 + ) + + assert total_row["data"]["name"] == "Итого" + expected_amount_q1 = sum((row["data"].get("amount_q1") or 0) for row in leaf_rows) + assert total_row["data"]["amount_q1"] == pytest.approx(expected_amount_q1) + assert total_row["data"]["amount_q1"] == pytest.approx(69645) + # «Итого» должна идти последней строкой листа (sort_order = 999999). + assert full_rows[-1] is total_row + + +def test_rent_plan_still_passed_through_without_division(client, admin_tokens, form2_id): + # Регрессия: AURORA-1897 не должна задевать перенос аренды/коммуналки/ + # охраны — только лимитные строки АХР лимитов. + response = client.post( + f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line", + json={"vsp_id": 1}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + rent_rows = response.json()["result"] + assert len(rent_rows) == 1 + rent_line_id = rent_rows[0]["data"]["id"] + + response = client.patch( + f"/api/v1/form/{form2_id}/sheet/AHR_RENT/cell", + json={"line_id": rent_line_id, "column": "plan.q1", "value": 100}, + 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 = _find_ahr_row_by_expense_item(ahr_rows, AHR_RENT_PARENT_EXPENSE_ITEM) + assert ahr_row["data"]["plan"]["q1"] == 100 -- 2.47.2 From fe9dfbc7e1785bfa5de65f51f019c52ac74a85d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 17:54:55 +0400 Subject: [PATCH 5/6] =?UTF-8?q?feat(form2):=20=D0=BF=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BA=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=90?= =?UTF-8?q?=D0=A5=D0=A0=20=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B0/=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B0=20(AURORA-1907)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 0053: v3.budget_line.source_sheet (NULL | AHR_LIMIT | AHR_RENT | AHR_UTILITY | AHR_SECURITY) — явный признак источника строки вместо сопоставления по совпадению имени с LEAF limit_template. Backfill существующих строк: сателлиты — из v3.form2_ahr_satellite_line_source, лимитные — минимальный id в группе (форма, статья, имя LEAF), остальные совпавшие по имени становятся ручными (RAISE NOTICE со списком). _ensure_form2_ahr_limit_sync_line теперь ищет строку по source_sheet='AHR_LIMIT' (а не по имени) — тот же класс бага, что чинил #265/AURORA-1909 для сателлитов аренды/коммуналки/охраны, теперь для лимитов. is_form2_ahr_satellite_line, del_budget_line (guard на удаление) и get_form2_ahr_satellite_delta переведены на признак источника. Новая v3._form2_expense_item_has_limit выносит правило «у статьи есть лимит» в отдельную функцию. v_form2_sheet_jsonb: header.source ('AHR_LIMIT'/'AHR_RENT'/'AHR_UTILITY'/ 'AHR_SECURITY'/'MANUAL'), header.is_over_limit для ручных строк на статьях с лимитом; plan.limit_year/over_limit_year (только на листе AHR, свёрнуты по дереву статей через mv_expense_item_tree.desc_ids) — plan.year не меняется, остаётся суммой всех строк. Фронт: AHR.js — колонки «В пределах лимита, год»/«Сверх лимита, год» после «План, год» (нередактируемые — нет случая в v3._apply_form_cell); Cell.jsx — чип «сверх лимита» в ячейке наименования по header.is_over_limit, без заливки строки. export_mappings.py — те же два поля в экспорт АХР. Co-Authored-By: Claude Sonnet 5 --- .../versions/0053_form2_ahr_source_sheet.py | 15 + .../sql/0053_form2_ahr_source_sheet.sql | 825 ++++++++++++++++++ api/src/db/models/budget_line.py | 1 + .../export_service/export_mappings.py | 12 + .../test_form2_ahr_satellite_integration.py | 335 +++++++ .../RealtimeTable/Cell/Cell/Cell.jsx | 25 +- .../RealtimeTable/constants/FORM_2/AHR.js | 40 + 7 files changed, 1251 insertions(+), 2 deletions(-) create mode 100644 api/alembic/versions/0053_form2_ahr_source_sheet.py create mode 100644 api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql diff --git a/api/alembic/versions/0053_form2_ahr_source_sheet.py b/api/alembic/versions/0053_form2_ahr_source_sheet.py new file mode 100644 index 0000000..a48c3cd --- /dev/null +++ b/api/alembic/versions/0053_form2_ahr_source_sheet.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0053" +down_revision = "0052" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0053_form2_ahr_source_sheet.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql b/api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql new file mode 100644 index 0000000..8ce866f --- /dev/null +++ b/api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql @@ -0,0 +1,825 @@ +-- AURORA-1907: признак источника строки АХР (v3.budget_line.source_sheet) и +-- раздельные суммы «в пределах лимита» / «сверх лимита» на листе АХР. +-- +-- Проблема (см. тикет): строка АХР опознаётся как «лимитная» только по +-- совпадению name с LEAF limit_template (is_form2_ahr_satellite_line, +-- 0031_form2_ahr_satellite_sync.sql:857-902; delete guard, +-- 0040_ahr_limit_delete_guard.sql:78-103; get_form2_ahr_satellite_delta, +-- 0031:784-855). Ручная строка исполнителя с тем же именем, что и LEAF, +-- ошибочно считается лимитной и захватывается синхронизацией лимитов +-- (_ensure_form2_ahr_limit_sync_line, 0031:245-273) — тот же класс бага, +-- что чинил #265/AURORA-1909 для сателлитов аренды/коммуналки/охраны, теперь +-- для лимитов через явный признак источника, а не совпадение имён. +-- +-- CREATE OR REPLACE ниже воспроизводят тела функций, актуальные на голове +-- миграций этой ветки (0052): _ensure_form2_ahr_detail_sync_line, +-- _ensure_form2_ahr_limit_sync_line, is_form2_ahr_satellite_line, +-- get_form2_ahr_satellite_delta — последний раз целиком определены в 0031 +-- и с тех пор не переопределялись (grep подтверждает: 0050/0051/0052 трогают +-- только _upsert_form2_ahr_detail_sync_values, sync_form2_ahr_satellite_lines/ +-- _line, v_form2_view, add_budget_line, _form2_ahr_limit_amount, +-- _limit_template_path_set — не эти пять). del_budget_line последний раз +-- целиком определён в 0040_ahr_limit_delete_guard.sql. v_form2_sheet_jsonb +-- последний раз целиком определён в 0015_aggregates_fix.sql:2534-2807. + + +-- 1. Колонка-признак источника строки. NULL — строку добавил пользователь. +ALTER TABLE v3.budget_line + ADD COLUMN source_sheet varchar NULL; + +ALTER TABLE v3.budget_line + ADD CONSTRAINT ck_v3_budget_line_source_sheet + CHECK (source_sheet IN ('AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY')); + + +-- 2а. Backfill: сателлиты аренды/коммуналки/охраны — однозначно по мэппингу +-- v3.form2_ahr_satellite_line_source (source_sheet/source_id -> target_line_id). +UPDATE v3.budget_line bl + SET source_sheet = m.source_sheet + FROM v3.form2_ahr_satellite_line_source m + WHERE bl.id = m.target_line_id + AND bl.source_sheet IS NULL; + + +-- 2б. Backfill: лимитные строки. Для каждого сочетания (форма, статья, имя +-- LEAF limit_template) строку с минимальным id (именно её обновляет +-- _ensure_form2_ahr_limit_sync_line/sync_form2_ahr_satellite_line(s)) помечаем +-- 'AHR_LIMIT'. Остальные строки с тем же именем остаются NULL (становятся +-- ручными — см. RAISE NOTICE ниже). Ограничено bf.form_type_code='FORM_2': +-- тот же expense_item_id может использоваться в FORM_1/FORM_4 АХР, где +-- лимитной синхронизации нет вовсе, и совпадение имени там чисто случайное. +WITH limit_candidates AS ( + SELECT + bl.id AS line_id, + MIN(bl.id) OVER (PARTITION BY bl.budget_form_id, bl.expense_item_id, leaf.leaf_name) AS min_line_id + FROM v3.budget_line bl + JOIN v3.budget_form bf + ON bf.id = bl.budget_form_id + AND bf.form_type_code = 'FORM_2' + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = ei.id + AND eft.form_type_code = 'FORM_2' + JOIN LATERAL ( + SELECT regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') AS leaf_name + FROM v3.limit_template lt + WHERE lt.row_type = 'LEAF' + AND 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 + ) + AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') + ) leaf ON TRUE + WHERE bl.source_sheet IS NULL +) +UPDATE v3.budget_line bl + SET source_sheet = 'AHR_LIMIT' + FROM limit_candidates lc + WHERE bl.id = lc.line_id + AND lc.line_id = lc.min_line_id; + + +-- 2в. Остальные строки, совпавшие по имени с LEAF лимита, но не минимальный +-- id по (форма, статья, имя) — остаются ручными (source_sheet NULL). +-- До этой миграции они были неотличимы от лимитных по имени; после неё они +-- — обычные ручные строки. Выводим список для ручной проверки администратором. +DO $do$ +DECLARE + v_rec record; +BEGIN + FOR v_rec IN + SELECT bl.id, bl.budget_form_id, bl.expense_item_id, bl.name + FROM v3.budget_line bl + JOIN v3.budget_form bf + ON bf.id = bl.budget_form_id + AND bf.form_type_code = 'FORM_2' + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = ei.id + AND eft.form_type_code = 'FORM_2' + JOIN v3.limit_template lt + ON lt.row_type = 'LEAF' + AND 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 + ) + WHERE bl.source_sheet IS NULL + AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') + GROUP BY bl.id, bl.budget_form_id, bl.expense_item_id, bl.name + LOOP + RAISE NOTICE 'AURORA-1907 backfill: v3.budget_line id=% (budget_form_id=%, expense_item_id=%, name=%) совпадает по имени с LEAF лимита, но не минимальный id в своей группе — остаётся ручной строкой (source_sheet=NULL)', + v_rec.id, v_rec.budget_form_id, v_rec.expense_item_id, v_rec.name; + END LOOP; +END; +$do$; + + +-- 3. _ensure_form2_ahr_detail_sync_line: ставить source_sheet соответствующего +-- листа при создании строки и при привязке к существующей (ветка rebind). +-- Тело — актуальное (0031, не менялось в 0050-0052) + source_sheet. +CREATE OR REPLACE FUNCTION v3._ensure_form2_ahr_detail_sync_line( + 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 +) +RETURNS integer +LANGUAGE plpgsql +AS $function$ +DECLARE + v_line_id integer; +BEGIN + SELECT m.target_line_id + INTO v_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 = p_source_sheet + AND m.source_id = p_source_id + AND bl.budget_form_id = p_form_id; + + IF v_line_id IS NULL THEN + SELECT bl.id + INTO v_line_id + FROM v3.budget_line bl + WHERE bl.id = p_existing_line_id + AND bl.budget_form_id = p_form_id + AND bl.expense_item_id = p_expense_item_id + AND NOT EXISTS ( + SELECT 1 + FROM v3.form2_ahr_satellite_line_source m + WHERE m.target_line_id = bl.id + ); + + IF v_line_id IS NULL THEN + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, vsp_id, direction, source_sheet + ) + VALUES ( + p_form_id, p_expense_item_id, p_name, p_internal_order, p_vsp_id, 'Support', p_source_sheet + ) + RETURNING id INTO v_line_id; + ELSE + UPDATE v3.budget_line + SET name = p_name, + internal_order = p_internal_order, + vsp_id = p_vsp_id, + direction = 'Support', + source_sheet = p_source_sheet, + updated_at = now() + WHERE id = v_line_id; + END IF; + + INSERT INTO v3.form2_ahr_satellite_line_source ( + source_sheet, source_id, target_line_id + ) + VALUES (p_source_sheet, p_source_id, v_line_id) + ON CONFLICT (source_sheet, source_id) DO UPDATE SET + target_line_id = EXCLUDED.target_line_id; + ELSE + UPDATE v3.budget_line + SET expense_item_id = p_expense_item_id, + name = p_name, + internal_order = p_internal_order, + vsp_id = p_vsp_id, + direction = 'Support', + source_sheet = p_source_sheet, + updated_at = now() + WHERE id = v_line_id; + END IF; + + RETURN v_line_id; +END; +$function$; + + +-- 4. _ensure_form2_ahr_limit_sync_line: искать только среди строк с +-- source_sheet = 'AHR_LIMIT' (а не по совпадению name) и ставить признак при +-- INSERT. Ручная строка с именем LEAF больше не захватывается синком лимитов. +-- Имя всё равно участвует в поиске — у одной статьи может быть несколько +-- LEAF лимитов (разные строки каталога с одним expense_item_code, см. +-- "Мобильная связь"/"- Директор"/"- Заместитель директора"/...), каждая со +-- своей строкой АХР; без имени они были бы неотличимы друг от друга. +CREATE OR REPLACE FUNCTION v3._ensure_form2_ahr_limit_sync_line( + p_form_id integer, + p_expense_item_id integer, + p_name character varying +) +RETURNS integer +LANGUAGE plpgsql +AS $function$ +DECLARE + v_line_id integer; +BEGIN + SELECT bl.id + INTO v_line_id + FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND bl.expense_item_id = p_expense_item_id + AND bl.source_sheet = 'AHR_LIMIT' + AND bl.name IS NOT DISTINCT FROM p_name + ORDER BY bl.id + LIMIT 1; + + IF v_line_id IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction, source_sheet) + VALUES (p_form_id, p_expense_item_id, p_name, 'Support', 'AHR_LIMIT') + RETURNING id INTO v_line_id; + ELSE + UPDATE v3.budget_line + SET name = p_name, + updated_at = now() + WHERE id = v_line_id; + END IF; + + RETURN v_line_id; +END; +$function$; + + +-- 5. is_form2_ahr_satellite_line: признак источника вместо совпадения имён. +CREATE OR REPLACE FUNCTION v3.is_form2_ahr_satellite_line( + p_form_id integer, + p_line_id integer +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $function$ + SELECT COALESCE( + (SELECT bl.source_sheet IS NOT NULL + FROM v3.budget_line bl + WHERE bl.id = p_line_id + AND bl.budget_form_id = p_form_id), + false + ); +$function$; + + +-- 6. del_budget_line: та же проверка — v_source_sheet IS NOT NULL вместо +-- двух EXISTS (мэппинг сателлитов + совпадение имени с LEAF лимита). Тело — +-- актуальное (0040, не менялось с тех пор) + замена одной проверки. +CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_form_id INT; + v_org_id INT; + v_sheet VARCHAR; + v_sat_table TEXT; + v_eid INT; + v_line_id INT; + v_ei_path INT[]; + v_source_sheet VARCHAR; +BEGIN + 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; + + EXECUTE format( + 'SELECT bl.budget_form_id FROM v3.%I s + JOIN v3.budget_line bl ON bl.id = s.line_id + WHERE s.id = $1', v_sat_table + ) USING p_id INTO v_form_id; + + IF v_form_id IS NULL THEN + RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_id; + END IF; + + SELECT m.target_line_id + INTO v_line_id + FROM v3.form2_ahr_satellite_line_source m + WHERE m.source_sheet = p_sheet + AND m.source_id = p_id; + + EXECUTE format('DELETE FROM v3.%I WHERE id = $1', v_sat_table) USING p_id; + + IF v_line_id IS NOT NULL THEN + SELECT bl.expense_item_id + INTO v_eid + FROM v3.budget_line bl + WHERE bl.id = v_line_id; + + 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; + + SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id; + + PERFORM v3.log_event( + 'ROW_DELETE', 'ROW', + jsonb_build_object( + 'sheet', 'AHR', + 'line_id', v_line_id, + 'entity_id', v_line_id, + 'expense_item_id', v_eid, + 'direction', 'Support', + 'source_sheet', p_sheet, + 'source_id', p_id + ), + v_form_id, NULL, v_org_id + ); + END IF; + RETURN; + END IF; + + SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.source_sheet + INTO v_form_id, v_sheet, v_eid, v_source_sheet + FROM v3.budget_line bl + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + WHERE bl.id = p_id; + + IF v_form_id IS NULL THEN + RAISE EXCEPTION 'budget_line id=% не существует', p_id; + END IF; + + IF v_sheet = 'AHR' AND v_source_sheet IS NOT NULL THEN + RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую' + USING ERRCODE = '23503', + HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты'; + END IF; + + v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]); + + DELETE FROM v3.plan WHERE line_id = p_id; + DELETE FROM v3.sequestration WHERE line_id = p_id; + DELETE FROM v3.reserve WHERE line_id = p_id; + DELETE FROM v3.allocation WHERE line_id = p_id; + DELETE FROM v3.contract_summary WHERE line_id = p_id; + DELETE FROM v3.collegial_approval WHERE line_id = p_id; + DELETE FROM v3.ckk WHERE line_id = p_id; + DELETE FROM v3.contract_detail WHERE line_id = p_id; + DELETE FROM v3.booking WHERE line_id = p_id; + DELETE FROM v3.budget_line_quarter WHERE line_id = p_id; + DELETE FROM v3.rent_detail WHERE line_id = p_id; + DELETE FROM v3.utility_detail WHERE line_id = p_id; + DELETE FROM v3.security_detail WHERE line_id = p_id; + + DELETE FROM v3.budget_line WHERE id = p_id; + + SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id; + + PERFORM v3.log_event( + 'ROW_DELETE', 'ROW', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', p_id, + 'entity_id', p_id, + 'expense_item_id', v_eid, + 'direction', p_direction + ), + v_form_id, NULL, v_org_id + ); + + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(v_form_id, v_sheet, NULL, p_direction) v + WHERE v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path) + ORDER BY v.sort_order; +END; +$function$; + + +-- 7. get_form2_ahr_satellite_delta: лимитные строки ищем по +-- source_sheet = 'AHR_LIMIT' (доп. фильтр к совпадению по имени) — манипулируя +-- именем, ручная строка больше не попадёт в дельту лимитной строки. Ветка +-- сателлитов (аренда/коммуналка/охрана) не трогается — там уже однозначное +-- сопоставление по v3.form2_ahr_satellite_line_source (source_id). Тело — +-- актуальное (0031, не менялось с тех пор) + один доп. AND. +CREATE OR REPLACE FUNCTION v3.get_form2_ahr_satellite_delta( + p_form_id integer, + p_source_sheet character varying, + p_source_row_id integer +) +RETURNS TABLE( + row_type character varying, + depth integer, + sort_order bigint, + data jsonb +) +LANGUAGE sql +STABLE +AS $function$ + WITH target_lines AS ( + SELECT m.target_line_id AS 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 = p_source_sheet + AND m.source_id = p_source_row_id + AND bl.budget_form_id = p_form_id + AND p_source_sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + + UNION + + SELECT bl.id AS line_id + FROM v3.limit_template lt + 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.budget_line bl + ON bl.budget_form_id = p_form_id + AND bl.expense_item_id = ei.id + AND bl.source_sheet = 'AHR_LIMIT' + AND bl.name = regexp_replace( + lt.name, + '^[[:space:]]*-[[:space:]]*', + '' + ) + WHERE p_source_sheet = 'AHR_LIMIT' + AND lt.id = p_source_row_id + AND lt.row_type = 'LEAF' + ), + target_path AS ( + SELECT unnest( + v3._expense_item_path_set( + COALESCE(array_agg(bl.expense_item_id), ARRAY[]::integer[]) + ) + ) AS expense_item_id + FROM target_lines tl + JOIN v3.budget_line bl ON bl.id = tl.line_id + ) + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, 'AHR', NULL, 'Support') v + WHERE ( + v.row_type IN ('ROOT', 'GROUP', 'ITEM', 'SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT IN ( + SELECT expense_item_id FROM target_path + ) + ) + OR ( + v.row_type = 'INPUT' + AND (v.data->>'line_id')::INT IN ( + SELECT line_id FROM target_lines + ) + ) + ORDER BY v.sort_order; +$function$; + + +-- 8. Правило «у статьи есть лимит» — вынесено в отдельную функцию, т.к. +-- используется и в header.is_over_limit, и в агрегатах plan.limit_year / +-- plan.over_limit_year (v_form2_sheet_jsonb ниже). +CREATE OR REPLACE FUNCTION v3._form2_expense_item_has_limit( + p_expense_item_id integer +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $function$ + SELECT EXISTS ( + SELECT 1 + FROM v3.limit_template lt + JOIN v3.expense_item ei + ON ei.id = p_expense_item_id + AND ei.sheet = 'AHR' + AND ei.direction = 'Support' + 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' + AND ( + (ei.depth = 3 AND ei.item_id = lt.expense_item_code) + OR ei.num_group_id = lt.expense_item_code + ) + ); +$function$; + + +-- 9. v_form2_sheet_jsonb: header.source / header.is_over_limit и +-- plan.limit_year / plan.over_limit_year (только на листе AHR; plan.year — +-- сумма всех строк, без изменений). Тело — актуальное (0015, не менялось с +-- тех пор) + источник строки и агрегаты лимита/сверхлимита. +CREATE OR REPLACE FUNCTION v3.v_form2_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, line_id integer, header jsonb, plan_data jsonb, seq_dfip_data jsonb, seq_ssp_data jsonb, approved_data jsonb, contract_data jsonb, booking_data jsonb, contract_summary jsonb, allocation_data jsonb, collegial_data jsonb, ckk_data jsonb, q1_data jsonb, q2_data jsonb, q3_data jsonb, q4_data jsonb, totals_data jsonb, _sort_path integer[]) + LANGUAGE sql + STABLE +AS $function$ + WITH flags AS ( + SELECT + (p_sections IS NULL OR 'plan' = ANY(p_sections)) AS s_plan, + (p_sections IS NULL OR 'seq_dfip' = ANY(p_sections)) AS s_seq_d, + (p_sections IS NULL OR 'seq_ssp' = ANY(p_sections)) AS s_seq_s, + (p_sections IS NULL OR 'approved' = ANY(p_sections)) AS s_appr, + (p_sections IS NULL OR 'contract' = ANY(p_sections)) AS s_cd, + (p_sections IS NULL OR 'booking' = ANY(p_sections)) AS s_book, + (p_sections IS NULL OR 'contract_summary' = ANY(p_sections)) AS s_cs, + (p_sections IS NULL OR 'allocation' = ANY(p_sections)) AS s_al, + (p_sections IS NULL OR 'collegial' = ANY(p_sections)) AS s_ca, + (p_sections IS NULL OR 'ckk' = ANY(p_sections)) AS s_ckk, + (p_sections IS NULL OR 'q1' = ANY(p_sections)) AS s_q1, + (p_sections IS NULL OR 'q2' = ANY(p_sections)) AS s_q2, + (p_sections IS NULL OR 'q3' = ANY(p_sections)) AS s_q3, + (p_sections IS NULL OR 'q4' = ANY(p_sections)) AS s_q4, + (p_sections IS NULL OR 'totals' = ANY(p_sections)) AS s_tot + ), + sheet_rows AS ( + SELECT s.*, + CASE WHEN s.row_type = 'INPUT' + THEN s._sort_path[array_upper(s._sort_path,1)-1] + ELSE s._sort_path[array_upper(s._sort_path,1)] END AS eid + FROM v3.v_form2_sheet_sections(p_form_id, p_sheet, p_sections) s + ), + limit_tree AS ( + SELECT + t.id AS expense_item_id, + COALESCE(SUM(CASE WHEN bl2.source_sheet = 'AHR_LIMIT' + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS limit_year, + COALESCE(SUM(CASE WHEN bl2.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl2.expense_item_id) + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS over_limit_year + FROM v3.mv_expense_item_tree t + JOIN v3.budget_line bl2 + ON bl2.expense_item_id = ANY(t.desc_ids) + AND bl2.budget_form_id = p_form_id + AND bl2.direction = 'Support' + LEFT JOIN v3.plan p2 ON p2.line_id = bl2.id + WHERE p_sheet = 'AHR' + AND t.sheet = p_sheet + GROUP BY t.id + ) + SELECT + s.row_type, s.depth, s.line_id, + + jsonb_build_object( + 'section_code', s.col_section_code, + 'item_id', s.col_item_id, + 'num_group', s.col_num_group_id, + 'name', s.col_name, + 'vsp_id', s.col_vsp_id, + 'vsp_address', s.col_vsp_address, + 'internal_order', s.col_internal_order, + 'expense_item_id', s.eid, + 'source', CASE WHEN s.row_type = 'INPUT' + THEN COALESCE(bl.source_sheet, 'MANUAL') + ELSE NULL END, + 'is_over_limit', (s.row_type = 'INPUT' + AND bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id)) + ) AS header, + + CASE WHEN f.s_plan THEN jsonb_build_object( + 'q1', s.col_plan_q1, 'q2', s.col_plan_q2, + 'q3', s.col_plan_q3, 'q4', s.col_plan_q4, + 'year', s.col_plan_year, 'comment', s.col_plan_comment + ) || CASE WHEN p_sheet = 'AHR' THEN jsonb_build_object( + 'limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet = 'AHR_LIMIT' + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.limit_year, 0) END, + 'over_limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id) + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.over_limit_year, 0) END + ) ELSE '{}'::jsonb END END AS plan_data, + + CASE WHEN f.s_seq_d THEN jsonb_build_object( + 'q1', s.col_seq_dfip_q1, 'q2', s.col_seq_dfip_q2, + 'q3', s.col_seq_dfip_q3, 'q4', s.col_seq_dfip_q4, + 'year', s.col_seq_dfip_year, + 'justification', s.col_seq_dfip_just + ) END AS seq_dfip_data, + + CASE WHEN f.s_seq_s THEN jsonb_build_object( + 'q1', s.col_seq_ssp_q1, 'q2', s.col_seq_ssp_q2, + 'q3', s.col_seq_ssp_q3, 'q4', s.col_seq_ssp_q4, + 'year', s.col_seq_ssp_year, + 'justification', s.col_seq_ssp_just + ) END AS seq_ssp_data, + + CASE WHEN f.s_appr THEN jsonb_build_object( + 'q1', s.col_appr_q1, 'q2', s.col_appr_q2, + 'q3', s.col_appr_q3, 'q4', s.col_appr_q4, + 'year', s.col_appr_year + ) END AS approved_data, + + CASE WHEN f.s_cd THEN jsonb_build_object( + 'counterparty', s.col_cd_counterparty, + 'reference', s.col_cd_reference, + 'addenda', s.col_cd_addenda, + 'date', TO_CHAR(s.col_cd_date, 'DD.MM.YYYY'), + 'subject', s.col_cd_subject, + 'currency', s.col_cd_currency, + 'ceiling', s.col_cd_ceiling, + 'q1', s.col_cd_exp_q1, + 'q2', s.col_cd_exp_q2, + 'q3', s.col_cd_exp_q3, + 'q4', s.col_cd_exp_q4, + 'rf_schedule', s.col_cd_rf_sched, + 'vat_rate', s.col_cd_vat_rate, + 'exchange_rate', s.col_cd_exchange_rate, + 'amount_foreign', s.col_cd_amount_foreign, + 'deadline', s.col_cd_deadline, + 'scheme', s.col_cd_scheme, + 'act', s.col_cd_act, + 'comment', s.col_cd_comment + ) END AS contract_data, + + CASE WHEN f.s_book THEN jsonb_build_object( + 'y2026', jsonb_build_object('q1', s.col_book_q1, 'q2', s.col_book_q2, 'q3', s.col_book_q3, 'q4', s.col_book_q4), + 'y2027', jsonb_build_object('q1', s.col_book_next_q1, 'q2', s.col_book_next_q2, 'q3', s.col_book_next_q3, 'q4', s.col_book_next_q4) + ) END AS booking_data, + + CASE WHEN f.s_cs THEN jsonb_build_object( + 'total', s.col_cs_total, + 'counterparty', s.col_cs_counterparty, + 'deadline', s.col_cs_deadline, + 'comment', s.col_cs_comment, + 'future_y1', s.col_cs_future_y1, + 'future_y2', s.col_cs_future_y2, + 'other_ssp', s.col_cs_other_ssp + ) END AS contract_summary, + + CASE WHEN f.s_al THEN jsonb_build_object( + 'contract_ref', s.col_al_contract_ref, + 'purpose', s.col_al_purpose, + 'order', s.col_al_order, + 'property', s.col_al_property + ) END AS allocation_data, + + CASE WHEN f.s_ca THEN jsonb_build_object( + 'approved', s.col_ca_approved, + 'protocol', s.col_ca_protocol, + 'note', s.col_ca_note + ) END AS collegial_data, + + CASE WHEN f.s_ckk THEN jsonb_build_object( + 'ceiling', s.col_ckk_ceiling, + 'rf_schedule', s.col_ckk_rf_sched, + 'deadline', s.col_ckk_deadline, + 'proc_plan', s.col_ckk_proc_plan, + 'proc_method', s.col_ckk_proc_meth, + 'comment', s.col_ckk_comment + ) END AS ckk_data, + + CASE WHEN f.s_q1 THEN jsonb_build_object( + 'adj_current', s.col_q1_adj_current, + 'adj_ssp', s.col_q1_adj_ssp, + 'adj_rf', s.col_q1_adj_rf, + 'adj_reserve', s.col_q1_adj_reserve, + 'adj_comment', s.col_q1_adj_comment, + 'corrected_plan', s.col_q1_corrected_plan, + 'pay_date', TO_CHAR(s.col_q1_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q1_pay_amount, + 'pay_ho', s.col_q1_pay_ho, + 'pay_rf', s.col_q1_pay_rf, + 'pay_comment', s.col_q1_pay_comment, + 'pay_act', s.col_q1_pay_act, + 'booking', s.col_q1_booking, + 'actual_m1', s.col_q1_actual_m1, + 'actual_m2', s.col_q1_actual_m2, + 'actual_m3', s.col_q1_actual_m3, + 'actual_quarter', s.col_q1_actual_quarter, + 'residual_after_booking', s.col_q1_residual_after_booking, + 'residual_after_actual', s.col_q1_residual_after_actual, + 'transfer_q2', s.col_q1_transfer_q2, + 'transfer_q2_delay_acts', s.col_q1_transfer_q2_delay_acts, + 'transfer_q2_delay_procurement', s.col_q1_transfer_q2_delay_procurement, + 'transfer_q2_economy_rf', s.col_q1_transfer_q2_economy_rf, + 'transfer_next_comment', s.col_q1_transfer_next_comment, + 'transfer_q3', s.col_q1_transfer_q3, + 'transfer_q4', s.col_q1_transfer_q4, + 'transfer_far_comment', s.col_q1_transfer_far_comment, + 'transfer_econ', s.col_q1_transfer_econ, + 'total', s.col_q1_total + ) END AS q1_data, + + CASE WHEN f.s_q2 THEN jsonb_build_object( + 'target_change', s.col_q2_target_change, + 'base_correction', s.col_q2_base_correction, + 'base_correction_comment', s.col_q2_base_correction_comment, + 'revision_inc', s.col_q2_revision_inc, + 'revision_seq', s.col_q2_revision_seq, + 'revision_comment', s.col_q2_revision_comment, + 'new_plan', s.col_q2_new_plan, + 'adj_current', s.col_q2_adj_current, + 'adj_ssp', s.col_q2_adj_ssp, + 'adj_rf', s.col_q2_adj_rf, + 'adj_reserve', s.col_q2_adj_reserve, + 'adj_comment', s.col_q2_adj_comment, + 'corrected_plan', s.col_q2_corrected_plan, + 'pay_date', TO_CHAR(s.col_q2_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q2_pay_amount, + 'pay_ho', s.col_q2_pay_ho, + 'pay_rf', s.col_q2_pay_rf, + 'pay_comment', s.col_q2_pay_comment, + 'pay_act', s.col_q2_pay_act, + 'booking', s.col_q2_booking, + 'actual_m1', s.col_q2_actual_m1, + 'actual_m2', s.col_q2_actual_m2, + 'actual_m3', s.col_q2_actual_m3, + 'actual_quarter', s.col_q2_actual_quarter, + 'residual_after_booking', s.col_q2_residual_after_booking, + 'residual_after_actual', s.col_q2_residual_after_actual, + 'transfer_q3', s.col_q2_transfer_q3, + 'transfer_q3_delay_acts', s.col_q2_transfer_q3_delay_acts, + 'transfer_q3_delay_procurement', s.col_q2_transfer_q3_delay_procurement, + 'transfer_q3_economy_rf', s.col_q2_transfer_q3_economy_rf, + 'transfer_next_comment', s.col_q2_transfer_next_comment, + 'transfer_q4', s.col_q2_transfer_q4, + 'transfer_far_comment', s.col_q2_transfer_far_comment, + 'transfer_econ', s.col_q2_transfer_econ, + 'total', s.col_q2_total + ) END AS q2_data, + + CASE WHEN f.s_q3 THEN jsonb_build_object( + 'target_change', s.col_q3_target_change, + 'base_correction', s.col_q3_base_correction, + 'base_correction_comment', s.col_q3_base_correction_comment, + 'revision_inc', s.col_q3_revision_inc, + 'revision_seq', s.col_q3_revision_seq, + 'revision_comment', s.col_q3_revision_comment, + 'new_plan', s.col_q3_new_plan, + 'adj_current', s.col_q3_adj_current, + 'adj_ssp', s.col_q3_adj_ssp, + 'adj_rf', s.col_q3_adj_rf, + 'adj_reserve', s.col_q3_adj_reserve, + 'adj_comment', s.col_q3_adj_comment, + 'corrected_plan', s.col_q3_corrected_plan, + 'pay_date', TO_CHAR(s.col_q3_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q3_pay_amount, + 'pay_ho', s.col_q3_pay_ho, + 'pay_rf', s.col_q3_pay_rf, + 'pay_comment', s.col_q3_pay_comment, + 'pay_act', s.col_q3_pay_act, + 'booking', s.col_q3_booking, + 'actual_m1', s.col_q3_actual_m1, + 'actual_m2', s.col_q3_actual_m2, + 'actual_m3', s.col_q3_actual_m3, + 'actual_quarter', s.col_q3_actual_quarter, + 'residual_after_booking', s.col_q3_residual_after_booking, + 'residual_after_actual', s.col_q3_residual_after_actual, + 'transfer_q4', s.col_q3_transfer_q4, + 'transfer_q4_delay_acts', s.col_q3_transfer_q4_delay_acts, + 'transfer_q4_delay_procurement', s.col_q3_transfer_q4_delay_procurement, + 'transfer_q4_economy_rf', s.col_q3_transfer_q4_economy_rf, + 'transfer_next_comment', s.col_q3_transfer_next_comment, + 'transfer_econ', s.col_q3_transfer_econ, + 'total', s.col_q3_total + ) END AS q3_data, + + CASE WHEN f.s_q4 THEN jsonb_build_object( + 'target_change', s.col_q4_target_change, + 'base_correction', s.col_q4_base_correction, + 'base_correction_comment', s.col_q4_base_correction_comment, + 'revision_inc', s.col_q4_revision_inc, + 'revision_seq', s.col_q4_revision_seq, + 'revision_comment', s.col_q4_revision_comment, + 'new_plan', s.col_q4_new_plan, + 'adj_current', s.col_q4_adj_current, + 'adj_ssp', s.col_q4_adj_ssp, + 'adj_rf', s.col_q4_adj_rf, + 'adj_reserve', s.col_q4_adj_reserve, + 'adj_comment', s.col_q4_adj_comment, + 'corrected_plan', s.col_q4_corrected_plan, + 'pay_date', TO_CHAR(s.col_q4_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q4_pay_amount, + 'pay_ho', s.col_q4_pay_ho, + 'pay_rf', s.col_q4_pay_rf, + 'pay_comment', s.col_q4_pay_comment, + 'pay_act', s.col_q4_pay_act, + 'booking', s.col_q4_booking, + 'actual_m1', s.col_q4_actual_m1, + 'actual_m2', s.col_q4_actual_m2, + 'actual_m3', s.col_q4_actual_m3, + 'actual_spod', s.col_q4_actual_spod, + 'actual_quarter', s.col_q4_actual_quarter, + 'residual_after_booking', s.col_q4_residual_after_booking, + 'residual_after_actual', s.col_q4_residual_after_actual, + 'transfer_econ', s.col_q4_transfer_econ, + 'total', s.col_q4_total + ) END AS q4_data, + + CASE WHEN f.s_tot THEN jsonb_build_object( + 'fact_year', s.col_fact_year, + 'pay_year', s.col_pay_year, + 'economy_year', s.col_economy_year + ) END AS totals_data, + + s._sort_path + FROM sheet_rows s + CROSS JOIN flags f + LEFT JOIN v3.budget_line bl ON bl.id = s.line_id + LEFT JOIN limit_tree lim ON lim.expense_item_id = s.eid + ORDER BY s._sort_path; +$function$ +; diff --git a/api/src/db/models/budget_line.py b/api/src/db/models/budget_line.py index 7ff54ef..ba0c6a1 100644 --- a/api/src/db/models/budget_line.py +++ b/api/src/db/models/budget_line.py @@ -29,6 +29,7 @@ class BudgetLine(Base): updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) direction: Mapped[str | None] = mapped_column(String) + source_sheet: Mapped[str | None] = mapped_column(String) # budget_form = relationship("BudgetForm", back_populates="budget_lines") # expense_item = relationship("ExpenseItem", back_populates="budget_lines") diff --git a/api/src/services/export_service/export_mappings.py b/api/src/services/export_service/export_mappings.py index 2398a18..1f8aabc 100644 --- a/api/src/services/export_service/export_mappings.py +++ b/api/src/services/export_service/export_mappings.py @@ -66,6 +66,18 @@ FORM1_DEPTH_COLUMNS: dict = { "year": { "name": "Год", }, + # AURORA-1907: лист АХР формы 2 — суммы «в пределах лимита» / + # «сверх лимита» (v3.v_form2_sheet_jsonb считает их только + # для p_sheet='AHR'; у СМЕТЫ/CAP/OPER и у FORM_1 этих ключей + # в data.plan нет вовсе, ячейки будут пустыми). Отдельного + # columns_dict для FORM_2/AHR не завели — он общий с + # OPER/CAP/FORM_1 (см. export_service._create_xlsxwriter_sheet_writers). + "limit_year": { + "name": "В пределах лимита, год", + }, + "over_limit_year": { + "name": "Сверх лимита, год", + }, "comment": { "name": "Комментарий", }, diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py index 5a33240..00c611f 100644 --- a/api/tests/integration/test_form2_ahr_satellite_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -85,6 +85,17 @@ def _find_ahr_row_by_line_id(rows: list[dict], line_id: int) -> dict: raise AssertionError(f"line_id={line_id} не найден на листе AHR: {rows}") +def _find_ahr_item_row(rows: list[dict], expense_item_id: int) -> dict: + # Статья-"лист" дерева расходов (depth=3, та, на которую вешаются + # budget_line) рендерится в v_form2_view как SUB_ITEM, а не ITEM — ITEM/ + # GROUP/ROOT — только промежуточные узлы выше по дереву. Проверено + # эмпирически через v3.v_form2_view на тестовой БД. + for row in rows: + if row["row_type"] == "SUB_ITEM" and row["data"]["header"].get("expense_item_id") == expense_item_id: + return row + raise AssertionError(f"SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: {[r for r in rows if r['row_type'] != 'INPUT']}") + + @pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) def test_satellite_line_add_does_not_capture_existing_manual_line( client, admin_tokens, auth_headers, sheet @@ -161,3 +172,327 @@ def test_two_satellite_records_on_empty_item_get_two_lines( second = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) assert first["line_id"] != second["line_id"] + + +# --- AURORA-1907: признак источника строки (source_sheet) и раздельные суммы +# «в пределах лимита» / «сверх лимита» на листе АХР ------------------------ +# +# "Мобильная связь" (expense_item_id=150 в реально накатанном каталоге — +# значения id из текста миграций 0003/0013 переопределяются более позднім +# re-seed'ом, проверено эмпирически через psql на тестовой БД, а не по тексту +# .sql) — статья каталога лимитов с 7 LEAF в limit_template (id=3..9: +# "- Директор", "- Заместитель директора", ...), все с +# expense_item_code='R061015039', совпадающим с ei.item_id статьи 150. +# Это единственная статья в тестовом каталоге, где несколько LEAF лимитов +# синкуются в разные строки одной и той же статьи — нужна для регрессионного +# теста на совпадение имени (п.4 тикета). +MOBILE_EXPENSE_ITEM_ID = 150 +# "Амортизация: НВНОД" (код R061161019) — статья FORM_2/AHR без лимита в +# каталоге (проверено эмпирически: v3._form2_expense_item_has_limit(95) = false). +NO_LIMIT_EXPENSE_ITEM_ID = 95 + + +def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_headers): + """GET .../sheet/AHR: у лимитных строк (созданных синком на листе лимитов + при создании формы) header.source == 'AHR_LIMIT'; у строки сателлита + аренды (после POST .../sheet/AHR_RENT/line) — 'AHR_RENT'.""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2040) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + limit_input_rows = [ + row + for row in ahr_rows + if row["row_type"] == "INPUT" + and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID + ] + assert limit_input_rows, ahr_rows + for row in limit_input_rows: + assert row["data"]["header"]["source"] == "AHR_LIMIT", row + + satellite_data = _add_satellite_line( + client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1 + ) + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + rent_row = _find_ahr_row_by_line_id(ahr_rows, satellite_data["line_id"]) + assert rent_row["data"]["header"]["source"] == "AHR_RENT" + + +def test_manual_line_on_limited_item_is_over_limit(client, admin_tokens, auth_headers): + """Ручная строка на статье с лимитом: header.source='MANUAL', + is_over_limit=true; у ITEM этой статьи over_limit_year учитывает только + ручную строку, limit_year — только лимитные строки, year — сумму обоих.""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2041) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 1000 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["is_over_limit"] is True + + item_row = _find_ahr_item_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + item_plan = item_row["data"]["plan"] + assert item_plan["over_limit_year"] == 1000 + assert item_plan["year"] == item_plan["limit_year"] + item_plan["over_limit_year"] + + +def test_manual_line_on_item_without_limit_is_not_over_limit(client, admin_tokens, auth_headers): + """Ручная строка на статье без лимита в каталоге: is_over_limit=false, + over_limit_year не растёт (AURORA-1907: «сверх лимита» считается только + для статей из листа лимитов).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2042) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, NO_LIMIT_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["is_over_limit"] is False + + item_row = _find_ahr_item_row(ahr_rows, NO_LIMIT_EXPENSE_ITEM_ID) + assert item_row["data"]["plan"]["over_limit_year"] == 0 + + +def test_manual_line_named_like_limit_leaf_is_not_captured_by_limit_sync( + client, admin_tokens, auth_headers +): + """Регрессия на баг, который фиксит AURORA-1907: ручная строка с именем, + совпадающим с LEAF лимита после снятия префикса "- " ("- Директор" -> + "Директор"), не должна захватываться _ensure_form2_ahr_limit_sync_line + при пересинхронизации (правка qty на листе лимитов) — план ручной строки + не меняется, и её можно удалить (до фикса она считалась лимитной и + удаление блокировалось v3.del_budget_line).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2043) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "header.name", "Директор" + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 321 + ) + + limit_rows_resp = client.get( + f"/api/v1/form/{form_id}/sheet/AHR_LIMIT", + headers=auth_headers(admin_tokens), + ) + assert limit_rows_resp.status_code == 200, limit_rows_resp.text + leaf_row = next( + row + for row in limit_rows_resp.json()["result"] + if row["row_type"] == "INPUT" and row["data"]["name"] == "- Директор" + ) + leaf_id = leaf_row["data"]["id"] + + patch_resp = client.patch( + f"/api/v1/form/{form_id}/sheet/AHR_LIMIT/cell", + json={"line_id": leaf_id, "column": "qty_q1", "value": 5}, + headers=auth_headers(admin_tokens), + ) + assert patch_resp.status_code == 200, patch_resp.text + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["name"] == "Директор" + assert manual_row["data"]["plan"]["q1"] == 321 + assert manual_row["data"]["header"]["source"] == "MANUAL" + + delete_resp = client.delete( + f"/api/v1/form/{form_id}/sheet/AHR/line/{manual_line_id}", + headers=auth_headers(admin_tokens), + ) + assert delete_resp.status_code == 200, delete_resp.text + + +async def test_backfill_sets_source_sheet_for_legacy_satellite_mapping(): + """AURORA-1907: backfill (0053_form2_ahr_source_sheet.sql, шаг 2а) должен + проставлять source_sheet строкам, у которых уже есть запись в + v3.form2_ahr_satellite_line_source, но source_sheet ещё не заполнен — + имитация состояния БД до миграции 0053. Отдельный engine (не общий + src.db.base.SessionLocal — его пул может держать asyncpg-соединения, + привязанные к event loop'у другого теста; NullPool+dispose исключают это), + всё в одной незакоммиченной транзакции — rollback в finally, другие тесты + не задевает.""" + from sqlalchemy import text + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + from src.core.config import settings + + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + Session = async_sessionmaker(bind=engine, expire_on_commit=False) + async with Session() as session: + try: + form_id = ( + await session.execute( + text( + "INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) " + "VALUES ('FORM_2', 2099, 1) RETURNING id" + ) + ) + ).scalar_one() + + line_id = ( + await session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) " + "VALUES (:form_id, 314, 'legacy rent row (AURORA-1907 backfill test)', 'Support') " + "RETURNING id" + ), + {"form_id": form_id}, + ) + ).scalar_one() + + await session.execute( + text( + "INSERT INTO v3.form2_ahr_satellite_line_source " + "(source_sheet, source_id, target_line_id) " + "VALUES ('AHR_RENT', 9999999, :line_id)" + ), + {"line_id": line_id}, + ) + + before = ( + await session.execute( + text("SELECT source_sheet FROM v3.budget_line WHERE id = :line_id"), + {"line_id": line_id}, + ) + ).scalar_one() + assert before is None + + # Тот же UPDATE, что в шаге 2а миграции 0053. + await session.execute( + text( + "UPDATE v3.budget_line bl " + "SET source_sheet = m.source_sheet " + "FROM v3.form2_ahr_satellite_line_source m " + "WHERE bl.id = m.target_line_id AND bl.source_sheet IS NULL" + ) + ) + + after = ( + await session.execute( + text("SELECT source_sheet FROM v3.budget_line WHERE id = :line_id"), + {"line_id": line_id}, + ) + ).scalar_one() + assert after == "AHR_RENT" + finally: + await session.rollback() + await engine.dispose() + + +async def test_backfill_limit_lines_picks_min_id_per_name(): + """AURORA-1907: backfill (0053_form2_ahr_source_sheet.sql, шаг 2б/2в) для + строк, совпадающих по имени с LEAF лимита "- Директор" (статья 129) — + строка с минимальным id помечается 'AHR_LIMIT', остальные с тем же именем + остаются ручными (source_sheet=NULL). Отдельный engine (см. комментарий в + test_backfill_sets_source_sheet_for_legacy_satellite_mapping выше), rollback + в finally.""" + from sqlalchemy import text + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + from src.core.config import settings + + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + Session = async_sessionmaker(bind=engine, expire_on_commit=False) + async with Session() as session: + try: + form_id = ( + await session.execute( + text( + "INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) " + "VALUES ('FORM_2', 2098, 1) RETURNING id" + ) + ) + ).scalar_one() + + first_id = ( + await session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) " + "VALUES (:form_id, :eid, 'Директор', 'Support') RETURNING id" + ), + {"form_id": form_id, "eid": MOBILE_EXPENSE_ITEM_ID}, + ) + ).scalar_one() + second_id = ( + await session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) " + "VALUES (:form_id, :eid, 'Директор', 'Support') RETURNING id" + ), + {"form_id": form_id, "eid": MOBILE_EXPENSE_ITEM_ID}, + ) + ).scalar_one() + assert first_id < second_id + + # Тот же UPDATE (шаг 2б миграции 0053). + await session.execute( + text( + """ + WITH limit_candidates AS ( + SELECT + bl.id AS line_id, + MIN(bl.id) OVER ( + PARTITION BY bl.budget_form_id, bl.expense_item_id, leaf.leaf_name + ) AS min_line_id + FROM v3.budget_line bl + JOIN v3.budget_form bf + ON bf.id = bl.budget_form_id + AND bf.form_type_code = 'FORM_2' + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.expense_item_form_type eft + ON eft.expense_item_id = ei.id + AND eft.form_type_code = 'FORM_2' + JOIN LATERAL ( + SELECT regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') AS leaf_name + FROM v3.limit_template lt + WHERE lt.row_type = 'LEAF' + AND 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 + ) + AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') + ) leaf ON TRUE + WHERE bl.source_sheet IS NULL + ) + UPDATE v3.budget_line bl + SET source_sheet = 'AHR_LIMIT' + FROM limit_candidates lc + WHERE bl.id = lc.line_id + AND lc.line_id = lc.min_line_id + """ + ) + ) + + rows = ( + await session.execute( + text( + "SELECT id, source_sheet FROM v3.budget_line " + "WHERE id IN (:first_id, :second_id) ORDER BY id" + ), + {"first_id": first_id, "second_id": second_id}, + ) + ).all() + by_id = {row.id: row.source_sheet for row in rows} + assert by_id[first_id] == "AHR_LIMIT" + assert by_id[second_id] is None + finally: + await session.rollback() + await engine.dispose() diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 7ada266..f3e0559 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -64,6 +64,23 @@ const CONTAINER_STYLES = { wordBreak: 'break-word', }; +// AURORA-1907: метка «сверх лимита» в ячейке наименования строки для ручных +// строк на статьях, у которых есть лимит (header.is_over_limit) — только чип, +// без заливки всей строки/ячейки цветом (см. AURORA-1890 для контекста). +const OVER_LIMIT_CHIP_STYLES = { + marginLeft: 6, + padding: '1px 6px', + borderRadius: 10, + fontSize: 10, + fontWeight: 600, + color: '#ffffff', + backgroundColor: '#D32F2F', + whiteSpace: 'nowrap', + flexShrink: 0, +}; + +const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']); + const TEXT_COLUMN_IDS = new Set([ 'data.header.num_group', 'data.nomenclature_group_id', @@ -142,7 +159,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, const isLocked = useCellLock(cellKey); const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown'; - const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8; + const isNameColumn = NAME_COLUMN_IDS.has(column.id); + const nameIndent = isNameColumn ? 8 + row.depth * 16 : 8; + const isOverLimit = isNameColumn && row.original?.data?.header?.is_over_limit === true; const { rawValue, displayValue, isNumeric } = useMemo(() => { const value = cell.getValue(); @@ -203,6 +222,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, // biome-ignore lint/a11y/useKeyWithClickEvents:
{highlightedContent} + {isOverLimit && сверх лимита} {lockBadge}
); @@ -221,7 +241,8 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { prevProps.isInvalid === nextProps.isInvalid && prevProps.isEditable === nextProps.isEditable && prevProps.vspOptions === nextProps.vspOptions && - prevProps.displayDivisor === nextProps.displayDivisor + prevProps.displayDivisor === nextProps.displayDivisor && + prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit ); }); diff --git a/web/src/components/RealtimeTable/constants/FORM_2/AHR.js b/web/src/components/RealtimeTable/constants/FORM_2/AHR.js index b4b34d7..3376b18 100644 --- a/web/src/components/RealtimeTable/constants/FORM_2/AHR.js +++ b/web/src/components/RealtimeTable/constants/FORM_2/AHR.js @@ -49,6 +49,14 @@ export const config = { color_type: greenColumn, accessorKey: "data.plan.year", }, + "data.plan.limit_year": { + color_type: greenColumn, + accessorKey: "data.plan.limit_year", + }, + "data.plan.over_limit_year": { + color_type: greenColumn, + accessorKey: "data.plan.over_limit_year", + }, "data.plan.comment": { color_type: greenColumn, accessorKey: "data.plan.comment", @@ -828,6 +836,38 @@ export const config = { }, }, }, + { + // AURORA-1907: вычисляемая колонка (суммы плана лимитных + // строк source_sheet='AHR_LIMIT' по дереву статей), + // нередактируемая — нет случая в v3._apply_form_cell для + // plan.limit_year, как и для соседних вычисляемых полей + // (например data.q1.actual_quarter). + header: "В пределах лимита, год", + accessorKey: "data.plan.limit_year", + size: 170, + filterFn: "contains", + muiTableHeadCellProps: { + sx: { + backgroundColor: "#FFFFFF", + color: "#000000", + }, + }, + }, + { + // AURORA-1907: вычисляемая колонка (план ручных строк на + // статьях с лимитом), нередактируемая — см. комментарий у + // data.plan.limit_year выше. + header: "Сверх лимита, год", + accessorKey: "data.plan.over_limit_year", + size: 170, + filterFn: "contains", + muiTableHeadCellProps: { + sx: { + backgroundColor: "#FFFFFF", + color: "#000000", + }, + }, + }, { header: "Комментарий", accessorKey: "data.plan.comment", -- 2.47.2 From 234103606b485334560c4b228b0274f511f32cb3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 21:59:04 +0400 Subject: [PATCH 6/6] =?UTF-8?q?feat(form2):=20=D1=81=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=B0=D1=82=D0=B5=D0=BB=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=B8=D1=81=D1=82=D0=B5=20=D0=90=D0=A5?= =?UTF-8?q?=D0=A0=20=D0=B8=20=D0=B7=D0=B0=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B0=D1=80=D0=B5=D0=BD=D0=B4=D1=8B/=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D1=83=D0=BD=D0=B0=D0=BB=D0=BA=D0=B8/=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20(AURORA-1887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Упрощённый дизайн (согласован в чате 2026-10-31, расходится с архитектурным предложением из тикета): без слияния budget_line, без миграции данных, без изменения протокола WS, без изменения v_svod2_rf. - v3.v_form2_sheet_jsonb (0054): при p_sheet='AHR' не возвращает INPUT-строки с budget_line.source_sheet IN ('AHR_LIMIT','AHR_RENT','AHR_UTILITY', 'AHR_SECURITY'); ручные строки (включая «сверх лимита», AURORA-1907) остаются видимыми. Листы-источники не затронуты. Суммы на заголовках статей считаются независимо от видимости строк — не меняются. - header.can_add_row (новое поле, только p_sheet='AHR'): false для статей аренды/коммуналки/охраны (v3._form2_satellite_expense_item, новый helper), true для лимитных и обычных статей. Фронт (RealtimeTable.jsx handleAddRow, utils/rowUtils.js canAddRowForHeader) блокирует «Добавить строку» по этому флагу — тот же паттерн, что у FormsNoCanAddRow/isVspAdditionRow (без серверной валидации в add_budget_line). - Обновлены тесты, которые проверяли суммы/источник через теперь скрытые INPUT-строки на AHR (test_form2_ahr_limit.py, test_form2_ahr_satellite_spod_integration.py, test_form2_ahr_satellite_integration.py) — сравнение переведено на заголовок статьи (до/после патча) и на лист-источник. Co-Authored-By: Claude Sonnet 5 --- .../0054_form2_ahr_hide_satellite_rows.py | 15 + .../0054_form2_ahr_hide_satellite_rows.sql | 418 ++++++++++++++++++ api/tests/integration/test_form2_ahr_limit.py | 131 ++++-- .../test_form2_ahr_satellite_integration.py | 292 +++++++++++- ...st_form2_ahr_satellite_spod_integration.py | 33 +- .../RealtimeTable/RealtimeTable.jsx | 26 +- .../RealtimeTable/utils/rowUtils.js | 10 + .../RealtimeTable/utils/rowUtils.test.js | 14 +- 8 files changed, 865 insertions(+), 74 deletions(-) create mode 100644 api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py create mode 100644 api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql diff --git a/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py b/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py new file mode 100644 index 0000000..64b3793 --- /dev/null +++ b/api/alembic/versions/0054_form2_ahr_hide_satellite_rows.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0054" +down_revision = "0053" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0054_form2_ahr_hide_satellite_rows.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql b/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql new file mode 100644 index 0000000..96283e8 --- /dev/null +++ b/api/alembic/versions/sql/0054_form2_ahr_hide_satellite_rows.sql @@ -0,0 +1,418 @@ +-- AURORA-1887: скрытие сателлитных INPUT-строк (лимит/аренда/коммуналка/ +-- охрана) на сводном листе АХР и блокировка «Добавить строку» для статей +-- аренды/коммуналки/охраны на этом листе. +-- +-- Упрощённый дизайн (согласован в чате 2026-10-31, расходится с исходным +-- текстом тикета): НЕ меняем модель budget_line, НЕ трогаем уникальность в +-- v3.form2_ahr_satellite_line_source, НЕ делаем миграцию слияния строк, НЕ +-- трогаем протокол WS, НЕ трогаем v3.v_svod2_rf (Свод 2 — построчная +-- детализация договоров там сознательно остаётся). +-- +-- Что меняется: +-- 1. v3.v_form2_sheet_jsonb: при p_sheet='AHR' не возвращаем INPUT-строки, +-- чей budget_line.source_sheet IN ('AHR_LIMIT','AHR_RENT','AHR_UTILITY', +-- 'AHR_SECURITY'). Строки с source_sheet IS NULL (ручные, в т.ч. «сверх +-- лимита» из AURORA-1907) остаются видимыми как есть. Листы-источники +-- (AHR_LIMIT/AHR_RENT/AHR_UTILITY/AHR_SECURITY) эта функция не +-- обслуживает (у них свои ветки в v3.v_form2_view, 0052) — там ничего не +-- меняется, полная детализация остаётся. Суммы на заголовках статей +-- (ITEM/SUB_ITEM) считаются в v3.v_form2_sheet_sections (CTE tw_agg) по +-- ВСЕМ строкам независимо от видимости в выдаче этой функции — фильтр +-- ниже влияет только на то, какие строки возвращаются, не на то, что +-- суммируется (limit_year/over_limit_year в этой же функции — тоже +-- отдельный агрегат по v3.budget_line/v3.mv_expense_item_tree, не по +-- видимым строкам). Также неявно решает вопрос WS-уведомлений: дельта +-- по сателлитной строке (v3.get_form2_ahr_satellite_delta, 0053) сама +-- вызывает v3.v_form_view(..., 'AHR', ...) → эту функцию, поэтому +-- скрытая INPUT-строка автоматически не попадёт и в WS-дельту; заголовки +-- (ROOT/GROUP/ITEM/SUB_ITEM) в дельте остаются и обновляют суммы. +-- websocket.py (notify_ahr_about_satellite_change) при этом не находит +-- INPUT-строку в дельте для source_event='row_added' и отправляет +-- cell_updated вместо row_added — обрабатывается существующим кодом +-- фронта (cellUtils.updateCells сопоставляет по row_type+depth+sort_order +-- и просто не находит несуществующую строку, без ошибки); row_deleted от +-- notify_ahr_about_satellite_change тоже безопасен — cellUtils.deleteRow +-- ищет lineId по всему дереву и является no-op, если не находит (проверено +-- чтением кода api/src/api/v1/websocket.py и +-- web/src/components/RealtimeTable/utils/cellUtils.js — не трогаем ни то, +-- ни другое, протокол WS не меняется). +-- 2. header.can_add_row (новое поле, вычисляется только при p_sheet='AHR'): +-- false для статей аренды/коммуналки/охраны (единственный expense_item_id +-- на каждую — см. v3._form2_satellite_expense_item ниже; те же id, что +-- зашиты в v3.add_budget_line, 0051_form2_ahr_satellite_new_line.sql:72-76, +-- 314/24/779 — проверены эмпирически на тестовой БД, см. ниже), true для +-- всех остальных статей, включая лимитные (AURORA-1907 «сверх лимита» не +-- трогаем — там единого expense_item_id у листа-источника нет вовсе, +-- только множество LEAF-статей). Это backend-флаг информационного +-- характера: фронт (RealtimeTable.jsx handleAddRow) читает его и +-- блокирует кнопку «Добавить строку» до вызова API — тот же паттерн, +-- что и у существующих правил добавления строк в этом компоненте +-- (FormsNoCanAddRow, isVspAdditionRow, isAdditionExpenseItem), ни одно из +-- которых не продублировано проверкой на backend в v3.add_budget_line; +-- тот же паттерн сохранён и здесь — без серверной валидации в +-- add_budget_line (рулинг AURORA-1887, описан в PR). +-- +-- Проверка id 314 (Аренда_ОН)/24 (Коммунальные услуги)/779 (Пультовая охрана +-- объектов недвижимости) — через psql на тестовой БД (разовый Postgres 16 в +-- Docker для этой задачи, см. PR): SELECT id, item_id, name, sheet, +-- direction, depth FROM v3.expense_item WHERE id IN (314,24,779); все три — +-- sheet='AHR', direction='Support', совпадают с текстом в +-- 0051_form2_ahr_satellite_new_line.sql и с комментарием в +-- tests/integration/test_form2_ahr_satellite_integration.py (SATELLITE_PARENT_EXPENSE_ITEM). +-- +-- v_form2_sheet_jsonb последний раз целиком определена в 0053 (воспроизводит +-- 0015_aggregates_fix.sql:2534-2807 + source_sheet/limit_year/over_limit_year +-- AURORA-1907). CREATE OR REPLACE ниже — та же версия + пункты 1-2 выше. + + +-- Новый небольшой helper: единственный expense_item_id листа-источника для +-- аренды/коммуналки/охраны. Для AHR_LIMIT единого id нет (множество LEAF +-- статей лимитов, каждая со своим expense_item_id) — NULL, как и для любого +-- другого p_sheet. +CREATE OR REPLACE FUNCTION v3._form2_satellite_expense_item(p_sheet character varying) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS $function$ + SELECT CASE p_sheet + WHEN 'AHR_RENT' THEN 314 + WHEN 'AHR_UTILITY' THEN 24 + WHEN 'AHR_SECURITY' THEN 779 + ELSE NULL + END; +$function$; + + +CREATE OR REPLACE FUNCTION v3.v_form2_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, line_id integer, header jsonb, plan_data jsonb, seq_dfip_data jsonb, seq_ssp_data jsonb, approved_data jsonb, contract_data jsonb, booking_data jsonb, contract_summary jsonb, allocation_data jsonb, collegial_data jsonb, ckk_data jsonb, q1_data jsonb, q2_data jsonb, q3_data jsonb, q4_data jsonb, totals_data jsonb, _sort_path integer[]) + LANGUAGE sql + STABLE +AS $function$ + WITH flags AS ( + SELECT + (p_sections IS NULL OR 'plan' = ANY(p_sections)) AS s_plan, + (p_sections IS NULL OR 'seq_dfip' = ANY(p_sections)) AS s_seq_d, + (p_sections IS NULL OR 'seq_ssp' = ANY(p_sections)) AS s_seq_s, + (p_sections IS NULL OR 'approved' = ANY(p_sections)) AS s_appr, + (p_sections IS NULL OR 'contract' = ANY(p_sections)) AS s_cd, + (p_sections IS NULL OR 'booking' = ANY(p_sections)) AS s_book, + (p_sections IS NULL OR 'contract_summary' = ANY(p_sections)) AS s_cs, + (p_sections IS NULL OR 'allocation' = ANY(p_sections)) AS s_al, + (p_sections IS NULL OR 'collegial' = ANY(p_sections)) AS s_ca, + (p_sections IS NULL OR 'ckk' = ANY(p_sections)) AS s_ckk, + (p_sections IS NULL OR 'q1' = ANY(p_sections)) AS s_q1, + (p_sections IS NULL OR 'q2' = ANY(p_sections)) AS s_q2, + (p_sections IS NULL OR 'q3' = ANY(p_sections)) AS s_q3, + (p_sections IS NULL OR 'q4' = ANY(p_sections)) AS s_q4, + (p_sections IS NULL OR 'totals' = ANY(p_sections)) AS s_tot + ), + sheet_rows AS ( + SELECT s.*, + CASE WHEN s.row_type = 'INPUT' + THEN s._sort_path[array_upper(s._sort_path,1)-1] + ELSE s._sort_path[array_upper(s._sort_path,1)] END AS eid + FROM v3.v_form2_sheet_sections(p_form_id, p_sheet, p_sections) s + ), + limit_tree AS ( + SELECT + t.id AS expense_item_id, + COALESCE(SUM(CASE WHEN bl2.source_sheet = 'AHR_LIMIT' + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS limit_year, + COALESCE(SUM(CASE WHEN bl2.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl2.expense_item_id) + THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0) + ELSE 0 END), 0) AS over_limit_year + FROM v3.mv_expense_item_tree t + JOIN v3.budget_line bl2 + ON bl2.expense_item_id = ANY(t.desc_ids) + AND bl2.budget_form_id = p_form_id + AND bl2.direction = 'Support' + LEFT JOIN v3.plan p2 ON p2.line_id = bl2.id + WHERE p_sheet = 'AHR' + AND t.sheet = p_sheet + GROUP BY t.id + ), + sat AS ( + SELECT + v3._form2_satellite_expense_item('AHR_RENT') AS rent_id, + v3._form2_satellite_expense_item('AHR_UTILITY') AS utility_id, + v3._form2_satellite_expense_item('AHR_SECURITY') AS security_id + ) + SELECT + s.row_type, s.depth, s.line_id, + + jsonb_build_object( + 'section_code', s.col_section_code, + 'item_id', s.col_item_id, + 'num_group', s.col_num_group_id, + 'name', s.col_name, + 'vsp_id', s.col_vsp_id, + 'vsp_address', s.col_vsp_address, + 'internal_order', s.col_internal_order, + 'expense_item_id', s.eid, + 'source', CASE WHEN s.row_type = 'INPUT' + THEN COALESCE(bl.source_sheet, 'MANUAL') + ELSE NULL END, + 'is_over_limit', (s.row_type = 'INPUT' + AND bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id)), + 'can_add_row', CASE WHEN p_sheet = 'AHR' + THEN NOT (COALESCE(s.eid, -1) = ANY(ARRAY[sat.rent_id, sat.utility_id, sat.security_id])) + ELSE NULL END + ) AS header, + + CASE WHEN f.s_plan THEN jsonb_build_object( + 'q1', s.col_plan_q1, 'q2', s.col_plan_q2, + 'q3', s.col_plan_q3, 'q4', s.col_plan_q4, + 'year', s.col_plan_year, 'comment', s.col_plan_comment + ) || CASE WHEN p_sheet = 'AHR' THEN jsonb_build_object( + 'limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet = 'AHR_LIMIT' + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.limit_year, 0) END, + 'over_limit_year', CASE WHEN s.row_type = 'INPUT' + THEN CASE WHEN bl.source_sheet IS NULL + AND v3._form2_expense_item_has_limit(bl.expense_item_id) + THEN COALESCE(s.col_plan_year, 0) ELSE 0 END + ELSE COALESCE(lim.over_limit_year, 0) END + ) ELSE '{}'::jsonb END END AS plan_data, + + CASE WHEN f.s_seq_d THEN jsonb_build_object( + 'q1', s.col_seq_dfip_q1, 'q2', s.col_seq_dfip_q2, + 'q3', s.col_seq_dfip_q3, 'q4', s.col_seq_dfip_q4, + 'year', s.col_seq_dfip_year, + 'justification', s.col_seq_dfip_just + ) END AS seq_dfip_data, + + CASE WHEN f.s_seq_s THEN jsonb_build_object( + 'q1', s.col_seq_ssp_q1, 'q2', s.col_seq_ssp_q2, + 'q3', s.col_seq_ssp_q3, 'q4', s.col_seq_ssp_q4, + 'year', s.col_seq_ssp_year, + 'justification', s.col_seq_ssp_just + ) END AS seq_ssp_data, + + CASE WHEN f.s_appr THEN jsonb_build_object( + 'q1', s.col_appr_q1, 'q2', s.col_appr_q2, + 'q3', s.col_appr_q3, 'q4', s.col_appr_q4, + 'year', s.col_appr_year + ) END AS approved_data, + + CASE WHEN f.s_cd THEN jsonb_build_object( + 'counterparty', s.col_cd_counterparty, + 'reference', s.col_cd_reference, + 'addenda', s.col_cd_addenda, + 'date', TO_CHAR(s.col_cd_date, 'DD.MM.YYYY'), + 'subject', s.col_cd_subject, + 'currency', s.col_cd_currency, + 'ceiling', s.col_cd_ceiling, + 'q1', s.col_cd_exp_q1, + 'q2', s.col_cd_exp_q2, + 'q3', s.col_cd_exp_q3, + 'q4', s.col_cd_exp_q4, + 'rf_schedule', s.col_cd_rf_sched, + 'vat_rate', s.col_cd_vat_rate, + 'exchange_rate', s.col_cd_exchange_rate, + 'amount_foreign', s.col_cd_amount_foreign, + 'deadline', s.col_cd_deadline, + 'scheme', s.col_cd_scheme, + 'act', s.col_cd_act, + 'comment', s.col_cd_comment + ) END AS contract_data, + + CASE WHEN f.s_book THEN jsonb_build_object( + 'y2026', jsonb_build_object('q1', s.col_book_q1, 'q2', s.col_book_q2, 'q3', s.col_book_q3, 'q4', s.col_book_q4), + 'y2027', jsonb_build_object('q1', s.col_book_next_q1, 'q2', s.col_book_next_q2, 'q3', s.col_book_next_q3, 'q4', s.col_book_next_q4) + ) END AS booking_data, + + CASE WHEN f.s_cs THEN jsonb_build_object( + 'total', s.col_cs_total, + 'counterparty', s.col_cs_counterparty, + 'deadline', s.col_cs_deadline, + 'comment', s.col_cs_comment, + 'future_y1', s.col_cs_future_y1, + 'future_y2', s.col_cs_future_y2, + 'other_ssp', s.col_cs_other_ssp + ) END AS contract_summary, + + CASE WHEN f.s_al THEN jsonb_build_object( + 'contract_ref', s.col_al_contract_ref, + 'purpose', s.col_al_purpose, + 'order', s.col_al_order, + 'property', s.col_al_property + ) END AS allocation_data, + + CASE WHEN f.s_ca THEN jsonb_build_object( + 'approved', s.col_ca_approved, + 'protocol', s.col_ca_protocol, + 'note', s.col_ca_note + ) END AS collegial_data, + + CASE WHEN f.s_ckk THEN jsonb_build_object( + 'ceiling', s.col_ckk_ceiling, + 'rf_schedule', s.col_ckk_rf_sched, + 'deadline', s.col_ckk_deadline, + 'proc_plan', s.col_ckk_proc_plan, + 'proc_method', s.col_ckk_proc_meth, + 'comment', s.col_ckk_comment + ) END AS ckk_data, + + CASE WHEN f.s_q1 THEN jsonb_build_object( + 'adj_current', s.col_q1_adj_current, + 'adj_ssp', s.col_q1_adj_ssp, + 'adj_rf', s.col_q1_adj_rf, + 'adj_reserve', s.col_q1_adj_reserve, + 'adj_comment', s.col_q1_adj_comment, + 'corrected_plan', s.col_q1_corrected_plan, + 'pay_date', TO_CHAR(s.col_q1_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q1_pay_amount, + 'pay_ho', s.col_q1_pay_ho, + 'pay_rf', s.col_q1_pay_rf, + 'pay_comment', s.col_q1_pay_comment, + 'pay_act', s.col_q1_pay_act, + 'booking', s.col_q1_booking, + 'actual_m1', s.col_q1_actual_m1, + 'actual_m2', s.col_q1_actual_m2, + 'actual_m3', s.col_q1_actual_m3, + 'actual_quarter', s.col_q1_actual_quarter, + 'residual_after_booking', s.col_q1_residual_after_booking, + 'residual_after_actual', s.col_q1_residual_after_actual, + 'transfer_q2', s.col_q1_transfer_q2, + 'transfer_q2_delay_acts', s.col_q1_transfer_q2_delay_acts, + 'transfer_q2_delay_procurement', s.col_q1_transfer_q2_delay_procurement, + 'transfer_q2_economy_rf', s.col_q1_transfer_q2_economy_rf, + 'transfer_next_comment', s.col_q1_transfer_next_comment, + 'transfer_q3', s.col_q1_transfer_q3, + 'transfer_q4', s.col_q1_transfer_q4, + 'transfer_far_comment', s.col_q1_transfer_far_comment, + 'transfer_econ', s.col_q1_transfer_econ, + 'total', s.col_q1_total + ) END AS q1_data, + + CASE WHEN f.s_q2 THEN jsonb_build_object( + 'target_change', s.col_q2_target_change, + 'base_correction', s.col_q2_base_correction, + 'base_correction_comment', s.col_q2_base_correction_comment, + 'revision_inc', s.col_q2_revision_inc, + 'revision_seq', s.col_q2_revision_seq, + 'revision_comment', s.col_q2_revision_comment, + 'new_plan', s.col_q2_new_plan, + 'adj_current', s.col_q2_adj_current, + 'adj_ssp', s.col_q2_adj_ssp, + 'adj_rf', s.col_q2_adj_rf, + 'adj_reserve', s.col_q2_adj_reserve, + 'adj_comment', s.col_q2_adj_comment, + 'corrected_plan', s.col_q2_corrected_plan, + 'pay_date', TO_CHAR(s.col_q2_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q2_pay_amount, + 'pay_ho', s.col_q2_pay_ho, + 'pay_rf', s.col_q2_pay_rf, + 'pay_comment', s.col_q2_pay_comment, + 'pay_act', s.col_q2_pay_act, + 'booking', s.col_q2_booking, + 'actual_m1', s.col_q2_actual_m1, + 'actual_m2', s.col_q2_actual_m2, + 'actual_m3', s.col_q2_actual_m3, + 'actual_quarter', s.col_q2_actual_quarter, + 'residual_after_booking', s.col_q2_residual_after_booking, + 'residual_after_actual', s.col_q2_residual_after_actual, + 'transfer_q3', s.col_q2_transfer_q3, + 'transfer_q3_delay_acts', s.col_q2_transfer_q3_delay_acts, + 'transfer_q3_delay_procurement', s.col_q2_transfer_q3_delay_procurement, + 'transfer_q3_economy_rf', s.col_q2_transfer_q3_economy_rf, + 'transfer_next_comment', s.col_q2_transfer_next_comment, + 'transfer_q4', s.col_q2_transfer_q4, + 'transfer_far_comment', s.col_q2_transfer_far_comment, + 'transfer_econ', s.col_q2_transfer_econ, + 'total', s.col_q2_total + ) END AS q2_data, + + CASE WHEN f.s_q3 THEN jsonb_build_object( + 'target_change', s.col_q3_target_change, + 'base_correction', s.col_q3_base_correction, + 'base_correction_comment', s.col_q3_base_correction_comment, + 'revision_inc', s.col_q3_revision_inc, + 'revision_seq', s.col_q3_revision_seq, + 'revision_comment', s.col_q3_revision_comment, + 'new_plan', s.col_q3_new_plan, + 'adj_current', s.col_q3_adj_current, + 'adj_ssp', s.col_q3_adj_ssp, + 'adj_rf', s.col_q3_adj_rf, + 'adj_reserve', s.col_q3_adj_reserve, + 'adj_comment', s.col_q3_adj_comment, + 'corrected_plan', s.col_q3_corrected_plan, + 'pay_date', TO_CHAR(s.col_q3_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q3_pay_amount, + 'pay_ho', s.col_q3_pay_ho, + 'pay_rf', s.col_q3_pay_rf, + 'pay_comment', s.col_q3_pay_comment, + 'pay_act', s.col_q3_pay_act, + 'booking', s.col_q3_booking, + 'actual_m1', s.col_q3_actual_m1, + 'actual_m2', s.col_q3_actual_m2, + 'actual_m3', s.col_q3_actual_m3, + 'actual_quarter', s.col_q3_actual_quarter, + 'residual_after_booking', s.col_q3_residual_after_booking, + 'residual_after_actual', s.col_q3_residual_after_actual, + 'transfer_q4', s.col_q3_transfer_q4, + 'transfer_q4_delay_acts', s.col_q3_transfer_q4_delay_acts, + 'transfer_q4_delay_procurement', s.col_q3_transfer_q4_delay_procurement, + 'transfer_q4_economy_rf', s.col_q3_transfer_q4_economy_rf, + 'transfer_next_comment', s.col_q3_transfer_next_comment, + 'transfer_econ', s.col_q3_transfer_econ, + 'total', s.col_q3_total + ) END AS q3_data, + + CASE WHEN f.s_q4 THEN jsonb_build_object( + 'target_change', s.col_q4_target_change, + 'base_correction', s.col_q4_base_correction, + 'base_correction_comment', s.col_q4_base_correction_comment, + 'revision_inc', s.col_q4_revision_inc, + 'revision_seq', s.col_q4_revision_seq, + 'revision_comment', s.col_q4_revision_comment, + 'new_plan', s.col_q4_new_plan, + 'adj_current', s.col_q4_adj_current, + 'adj_ssp', s.col_q4_adj_ssp, + 'adj_rf', s.col_q4_adj_rf, + 'adj_reserve', s.col_q4_adj_reserve, + 'adj_comment', s.col_q4_adj_comment, + 'corrected_plan', s.col_q4_corrected_plan, + 'pay_date', TO_CHAR(s.col_q4_pay_date, 'DD.MM.YYYY'), + 'pay_amount', s.col_q4_pay_amount, + 'pay_ho', s.col_q4_pay_ho, + 'pay_rf', s.col_q4_pay_rf, + 'pay_comment', s.col_q4_pay_comment, + 'pay_act', s.col_q4_pay_act, + 'booking', s.col_q4_booking, + 'actual_m1', s.col_q4_actual_m1, + 'actual_m2', s.col_q4_actual_m2, + 'actual_m3', s.col_q4_actual_m3, + 'actual_spod', s.col_q4_actual_spod, + 'actual_quarter', s.col_q4_actual_quarter, + 'residual_after_booking', s.col_q4_residual_after_booking, + 'residual_after_actual', s.col_q4_residual_after_actual, + 'transfer_econ', s.col_q4_transfer_econ, + 'total', s.col_q4_total + ) END AS q4_data, + + CASE WHEN f.s_tot THEN jsonb_build_object( + 'fact_year', s.col_fact_year, + 'pay_year', s.col_pay_year, + 'economy_year', s.col_economy_year + ) END AS totals_data, + + s._sort_path + FROM sheet_rows s + CROSS JOIN flags f + CROSS JOIN sat + LEFT JOIN v3.budget_line bl ON bl.id = s.line_id + LEFT JOIN limit_tree lim ON lim.expense_item_id = s.eid + WHERE NOT ( + p_sheet = 'AHR' + AND s.row_type = 'INPUT' + AND bl.source_sheet IS NOT NULL + AND bl.source_sheet IN ('AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') + ) + ORDER BY s._sort_path; +$function$ +; diff --git a/api/tests/integration/test_form2_ahr_limit.py b/api/tests/integration/test_form2_ahr_limit.py index b261ccd..55812df 100644 --- a/api/tests/integration/test_form2_ahr_limit.py +++ b/api/tests/integration/test_form2_ahr_limit.py @@ -73,91 +73,128 @@ def _find_by_id(rows: list[dict], target_id: int) -> dict: raise AssertionError(f"строка с id={target_id} не найдена среди {rows!r}") -def _find_ahr_row_by_name(rows: list[dict], name: str) -> dict: +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # AURORA-1887: лимитные INPUT-строки на листе AHR теперь скрыты (не + # выводятся отдельными строками) — раньше этот файл проверял сумму + # напрямую на INPUT-строке по имени/expense_item_id, сейчас сравниваем + # через заголовок статьи. Заголовок — ITEM/SUB_ITEM/SUB_SUB_ITEM в + # зависимости от глубины конкретной статьи в дереве расходов (проверено + # эмпирически через v3.expense_item на тестовой БД: «ДМС» и «Мобильная + # связь» — depth=3 (SUB_ITEM), «оргтехника»/«кассовое оборудование» — + # depth=4 (SUB_SUB_ITEM)). for row in rows: - if row.get("row_type") != "INPUT": - continue - data = row.get("data") or {} - if (data.get("header") or {}).get("name") == name: - return row - raise AssertionError(f"строка АХР с именем {name!r} не найдена среди {rows!r}") - - -def _find_ahr_row_by_expense_item(rows: list[dict], expense_item_id: int) -> dict: - for row in rows: - if row.get("row_type") != "INPUT": + if row.get("row_type") not in ("ITEM", "SUB_ITEM", "SUB_SUB_ITEM"): continue data = row.get("data") or {} if (data.get("header") or {}).get("expense_item_id") == expense_item_id: return row raise AssertionError( - f"строка АХР с expense_item_id={expense_item_id} не найдена среди {rows!r}" + f"заголовок статьи expense_item_id={expense_item_id} не найден на листе AHR " + f"среди {[r for r in rows if r.get('row_type') != 'INPUT']!r}" ) def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id): - # id=24 «- стандарт РФ»: ДМС, лимит 27858 руб/год без НДС, period=YEAR — - # 10 * 27858 / 4 = 69645 руб. в заполненный квартал (а не дублирование - # годовой суммы 278580, как было до миграции). + # id=24 «- стандарт РФ»: ДМС (expense_item_id=456), лимит 27858 руб/год + # без НДС, period=YEAR — 10 * 27858 / 4 = 69645 руб. в заполненный + # квартал (а не дублирование годовой суммы 278580, как было до + # миграции). AURORA-1887: INPUT-строка на AHR теперь скрыта — сравниваем + # приращение суммы на заголовке статьи (ITEM/SUB_ITEM/SUB_SUB_ITEM) до и + # после патча, не абсолютное значение (на статье могут быть и другие + # лимитные LEAF из сидовых данных). + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10) row = _find_by_id(rows, 24) assert row["data"]["amount_q1"] == 69645 assert row["data"]["amount_year"] == 69645 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "стандарт РФ") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(69.645) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 456 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(69.645) def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id): - # id=3 «- Директор»: 1 чел/квартал, 2400 руб. с НДС / 2000 без НДС — - # сумма должна считаться от лимита БЕЗ НДС. + # id=3 «- Директор» (expense_item_id=150, «Мобильная связь»): 1 + # чел/квартал, 2400 руб. с НДС / 2000 без НДС — сумма должна считаться + # от лимита БЕЗ НДС. AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q2"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1) row = _find_by_id(rows, 3) assert row["data"]["amount_q2"] == 2000 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "Директор") - assert ahr_row["data"]["plan"]["q2"] == pytest.approx(2.0) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q2"] or 0 + assert (after - before) == pytest.approx(2.0) def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, form2_id): - # id=7 «- SIM-карта для планшета (легкий фронт)»: limit_without_vat был - # NULL до миграции — она заполнила его 375 (450 / 1.2). Сумма не должна - # стать 0 после перехода на расчёт без НДС. + # id=7 «- SIM-карта для планшета (легкий фронт)» (expense_item_id=150, + # «Мобильная связь»): limit_without_vat был NULL до миграции — она + # заполнила его 375 (450 / 1.2). Сумма не должна стать 0 после перехода + # на расчёт без НДС. AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20) row = _find_by_id(rows, 7) assert row["data"]["amount_q1"] == 7500 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "SIM-карта для планшета (легкий фронт)") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(7.5) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 150 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(7.5) def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_id): - # id=13 «- принтеры»: раньше считалось по захардкоженному имени + # id=13 «- принтеры» (expense_item_id=167, «ТО/экспл оргтехника», + # depth=4/SUB_SUB_ITEM): раньше считалось по захардкоженному имени # (qty * 0.57 * 10900 / 4), теперь — общей формулой через period=YEAR, # limit_without_vat=5177.5 (миграция переписала справочник). + # AURORA-1887: сравниваем приращение на заголовке статьи — см. + # комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 + )["data"]["plan"]["q1"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5) row = _find_by_id(rows, 13) assert row["data"]["amount_q1"] == pytest.approx(6471.875) - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "принтеры") - assert ahr_row["data"]["plan"]["q1"] == pytest.approx(6.471875) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 167 + )["data"]["plan"]["q1"] or 0 + assert (after - before) == pytest.approx(6.471875) def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, form2_id): - # id=17 «- счетчики/сортировщики банкнот»: квартальный норматив (period + # id=17 «- счетчики/сортировщики банкнот» (expense_item_id=179, «ТО/ + # экспл касс обор», depth=4/SUB_SUB_ITEM): квартальный норматив (period # остаётся QUARTER по умолчанию) считается как раньше — поменялся только - # источник суммы (НДС). + # источник суммы (НДС). AURORA-1887: сравниваем приращение на заголовке + # статьи — см. комментарий в test_annual_dms_limit_divided_by_four. + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 + )["data"]["plan"]["q3"] or 0 + rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2) row = _find_by_id(rows, 17) assert row["data"]["amount_q3"] == 7500 - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_name(ahr_rows, "счетчики/сортировщики банкнот") - assert ahr_row["data"]["plan"]["q3"] == pytest.approx(7.5) + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), 179 + )["data"]["plan"]["q3"] or 0 + assert (after - before) == pytest.approx(7.5) def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, form2_id): @@ -189,7 +226,14 @@ def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, for def test_rent_plan_still_passed_through_without_division(client, admin_tokens, form2_id): # Регрессия: AURORA-1897 не должна задевать перенос аренды/коммуналки/ - # охраны — только лимитные строки АХР лимитов. + # охраны — только лимитные строки АХР лимитов. AURORA-1887: INPUT-строка + # записи аренды на AHR теперь скрыта — сравниваем приращение на + # заголовке статьи аренды (не абсолютное значение: на статье могут уже + # быть другие записи аренды из сидовых данных). + before = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM + )["data"]["plan"]["q1"] or 0 + response = client.post( f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line", json={"vsp_id": 1}, @@ -207,6 +251,7 @@ def test_rent_plan_still_passed_through_without_division(client, admin_tokens, f ) assert response.status_code == 200, response.text - ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR") - ahr_row = _find_ahr_row_by_expense_item(ahr_rows, AHR_RENT_PARENT_EXPENSE_ITEM) - assert ahr_row["data"]["plan"]["q1"] == 100 + after = _find_ahr_header_row( + _get_sheet_rows(client, admin_tokens, form2_id, "AHR"), AHR_RENT_PARENT_EXPENSE_ITEM + )["data"]["plan"]["q1"] or 0 + assert (after - before) == 100 diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py index 00c611f..39ca5d7 100644 --- a/api/tests/integration/test_form2_ahr_satellite_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -48,15 +48,19 @@ def _add_manual_ahr_line(client, admin_tokens, auth_headers, form_id, expense_it return input_rows[0]["data"]["line_id"] -def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): +def _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, line_id, column, value): response = client.patch( - f"/api/v1/form/{form_id}/sheet/AHR/cell", + f"/api/v1/form/{form_id}/sheet/{sheet}/cell", json={"line_id": line_id, "column": column, "value": value}, headers=auth_headers(admin_tokens), ) assert response.status_code == 200, response.text +def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): + _patch_cell(client, admin_tokens, auth_headers, form_id, "AHR", line_id, column, value) + + def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict: response = client.post( f"/api/v1/form/{form_id}/sheet/{sheet}/line", @@ -96,6 +100,21 @@ def _find_ahr_item_row(rows: list[dict], expense_item_id: int) -> dict: raise AssertionError(f"SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: {[r for r in rows if r['row_type'] != 'INPUT']}") +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # Обобщение _find_ahr_item_row: не все статьи-"листья" дерева расходов на + # глубине 3 (SUB_ITEM) — Коммунальные услуги (expense_item_id=24) лежат на + # глубине 2 (ITEM), т.к. у этой статьи нет собственной под-ветки. Ищем по + # обоим типам (проверено эмпирически через v3.expense_item на тестовой БД: + # depth=2 для 24, depth=3 для 150/314/779/95). + for row in rows: + if row["row_type"] in ("ITEM", "SUB_ITEM") and row["data"]["header"].get("expense_item_id") == expense_item_id: + return row + raise AssertionError( + f"ITEM/SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: " + f"{[r for r in rows if r['row_type'] != 'INPUT']}" + ) + + @pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) def test_satellite_line_add_does_not_capture_existing_manual_line( client, admin_tokens, auth_headers, sheet @@ -103,7 +122,12 @@ def test_satellite_line_add_does_not_capture_existing_manual_line( """Основной сценарий бага: у статьи уже есть ручная строка (и больше ни одной), затем на лист-сателлит добавляется запись. Ручная строка не должна быть переименована/перезаписана/привязана — запись должна - получить свою собственную строку АХР.""" + получить свою собственную строку АХР. + + AURORA-1887: эта собственная строка синка сателлита теперь скрыта на + самом листе AHR (не выводится отдельной INPUT-строкой) — проверяем её + через лист-источник (там ничего не прячем) и явно убеждаемся, что на AHR + её действительно больше нет.""" expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] form_id = _create_form2(client, admin_tokens, auth_headers, year=2030) @@ -132,9 +156,20 @@ def test_satellite_line_add_does_not_capture_existing_manual_line( assert manual_row["data"]["header"]["name"] == "моя строка" assert manual_row["data"]["plan"]["q1"] == 500 - # у записи сателлита — отдельная строка на листе AHR (строка синка) - satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id) - assert satellite_row["data"]["line_id"] == satellite_line_id + # у записи сателлита — отдельная строка синка, но она скрыта на самом + # листе AHR (AURORA-1887); проверяем на листе-источнике (там видно всё). + rent_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) + satellite_row = next( + (row for row in rent_rows if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_line_id), + None, + ) + assert satellite_row is not None, rent_rows + + hidden_on_ahr = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_line_id + ] + assert hidden_on_ahr == [], hidden_on_ahr @pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) @@ -193,9 +228,17 @@ NO_LIMIT_EXPENSE_ITEM_ID = 95 def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_headers): - """GET .../sheet/AHR: у лимитных строк (созданных синком на листе лимитов - при создании формы) header.source == 'AHR_LIMIT'; у строки сателлита - аренды (после POST .../sheet/AHR_RENT/line) — 'AHR_RENT'.""" + """До AURORA-1887 эта проверка читала header.source == 'AHR_LIMIT' / + 'AHR_RENT' напрямую с GET .../sheet/AHR. AURORA-1887 прячет эти + INPUT-строки с листа AHR по тому же признаку (source_sheet) — их больше + не видно там отдельными строками, поэтому header.source для них на этом + листе больше не наблюдаем (сам признак и его корректность продолжают + проверяться: test_limit_leaf_input_rows_hidden_on_ahr_but_header_sum_correct + и test_satellite_input_row_hidden_on_ahr_but_visible_on_source_sheet ниже + — через сумму на заголовке статьи и через лист-источник соответственно). + Здесь остаётся узкая регрессия по актуальному контракту: обе строки + (лимитная и строка сателлита аренды) не выводятся отдельными INPUT на + листе AHR.""" form_id = _create_form2(client, admin_tokens, auth_headers, year=2040) ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) @@ -205,16 +248,17 @@ def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_ if row["row_type"] == "INPUT" and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID ] - assert limit_input_rows, ahr_rows - for row in limit_input_rows: - assert row["data"]["header"]["source"] == "AHR_LIMIT", row + assert limit_input_rows == [], limit_input_rows satellite_data = _add_satellite_line( client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1 ) ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) - rent_row = _find_ahr_row_by_line_id(ahr_rows, satellite_data["line_id"]) - assert rent_row["data"]["header"]["source"] == "AHR_RENT" + rent_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == satellite_data["line_id"] + ] + assert rent_input_rows == [], rent_input_rows def test_manual_line_on_limited_item_is_over_limit(client, admin_tokens, auth_headers): @@ -496,3 +540,223 @@ async def test_backfill_limit_lines_picks_min_id_per_name(): finally: await session.rollback() await engine.dispose() + + +# --- AURORA-1887: скрыть сателлитные INPUT-строки на сводном листе АХР и +# заблокировать «Добавить строку» для статей аренды/коммуналки/охраны ------- +# +# Упрощённый дизайн (согласован в чате 2026-10-31): НЕ меняем модель +# budget_line, НЕ трогаем уникальность в form2_ahr_satellite_line_source, НЕ +# делаем миграцию слияния строк, НЕ трогаем протокол WS, НЕ трогаем +# v3.v_svod2_rf. Только v3.v_form2_sheet_jsonb (0054): при p_sheet='AHR' +# скрывает INPUT-строки с budget_line.source_sheet IN ('AHR_LIMIT', +# 'AHR_RENT','AHR_UTILITY','AHR_SECURITY') и добавляет header.can_add_row. +# +# "Коммунальные услуги" (expense_item_id=24) — на глубине 2 дерева расходов +# (row_type='ITEM'), а не 3 (SUB_ITEM), как 150/314/779/95 — проверено +# эмпирически через v3.expense_item на тестовой БД; см. _find_ahr_header_row. + + +def _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) -> list[dict]: + response = client.get( + f"/api/v1/form/{form_id}/sheet/{sheet}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def test_limit_leaf_input_rows_hidden_on_ahr_but_header_sum_correct( + client, admin_tokens, auth_headers +): + """AURORA-1887, п.1 теста: статья с лимитом (Мобильная связь, + MOBILE_EXPENSE_ITEM_ID=150, 7 LEAF в limit_template) — на листе AHR НЕТ + отдельных INPUT-строк на каждый LEAF, но заголовок статьи (SUB_ITEM) + присутствует с правильной суммой. + + Сумма проверяется НЕ прямым SQL-подсчётом по v3.plan (сессия client + работает в незакоммиченной транзакции — отдельное соединение/engine её + не увидит, см. комментарий к test_backfill_* выше про отдельный engine + для ИМИТАЦИИ состояния до миграции, а не для чтения состояния чужой + незакоммиченной транзакции), а сравнением с независимым путём расчёта + той же величины: GET .../sheet/AHR_LIMIT считает amount_year каждого LEAF + напрямую через v3._form2_ahr_limit_amount (без деления на 1000); синк в + v3.plan родительской строки АХР делит ту же величину на 1000 + (ab765f0/0031/0052) — это и есть plan.limit_year статьи на листе AHR. + Совпадение с точностью до округления подтверждает, что сумма за + скрытыми строками считается корректно независимо от того, что сами + строки в выдаче отсутствуют. + """ + form_id = _create_form2(client, admin_tokens, auth_headers, year=2050) + + limit_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, "AHR_LIMIT") + mobile_leaves = [ + row for row in limit_rows + if row["row_type"] == "INPUT" and row["data"].get("expense_item_code") == "R061015039" + ] + assert len(mobile_leaves) == 7, mobile_leaves + + # Патчим qty_q1 двум LEAF произвольными (разными) значениями — остальные + # остаются нулевыми/NULL. + _patch_cell( + client, admin_tokens, auth_headers, form_id, "AHR_LIMIT", + mobile_leaves[0]["data"]["id"], "qty_q1", 5, + ) + _patch_cell( + client, admin_tokens, auth_headers, form_id, "AHR_LIMIT", + mobile_leaves[1]["data"]["id"], "qty_q1", 2, + ) + + limit_rows_after = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, "AHR_LIMIT") + mobile_leaves_after = [ + row for row in limit_rows_after + if row["row_type"] == "INPUT" and row["data"].get("expense_item_code") == "R061015039" + ] + expected_limit_year = sum( + (row["data"].get("amount_year") or 0) for row in mobile_leaves_after + ) + assert expected_limit_year > 0, mobile_leaves_after + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + # Нет ни одной отдельной INPUT-строки на статье лимита — все 7 LEAF + # скрыты. + mobile_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID + ] + assert mobile_input_rows == [], mobile_input_rows + + item_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + item_plan = item_row["data"]["plan"] + assert item_plan["limit_year"] == pytest.approx(expected_limit_year / 1000, abs=1e-6) + assert item_plan["over_limit_year"] == 0 + assert item_plan["year"] == pytest.approx(item_plan["limit_year"], abs=1e-6) + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_input_row_hidden_on_ahr_but_visible_on_source_sheet( + client, admin_tokens, auth_headers, sheet +): + """AURORA-1887, п.2-3 теста: запись аренды/коммуналки/охраны (POST + .../sheet/{sheet}/line + PATCH plan.q1) не видна отдельной INPUT-строкой + на листе AHR, но сумма заголовка статьи корректна (п.2); на самом + листе-источнике запись видна как обычно — регрессия на «на + листах-источниках ничего не прячем» (п.3).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2051) + + satellite_data = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + line_id = satellite_data["line_id"] + detail_id = satellite_data["id"] + + _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, detail_id, "plan.q1", 777) + + # п.2: скрыта на AHR, сумма статьи верна (на свежей форме это единственная + # строка статьи — сумма год равна ровно патченному значению). + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + hidden_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id + ] + assert hidden_input_rows == [], hidden_input_rows + + header_row = _find_ahr_header_row(ahr_rows, expense_item_id) + assert header_row["data"]["plan"]["q1"] == 777 + assert header_row["data"]["plan"]["year"] == 777 + + # п.3: на листе-источнике запись видна как обычно (регрессия). + source_rows = _get_sheet_rows(client, admin_tokens, auth_headers, form_id, sheet) + source_row = next( + (row for row in source_rows if row["row_type"] == "INPUT" and row["data"].get("id") == detail_id), + None, + ) + assert source_row is not None, source_rows + assert source_row["data"]["plan"]["q1"] == 777 + + +def test_manual_overlimit_line_visible_while_limit_input_rows_stay_hidden( + client, admin_tokens, auth_headers +): + """AURORA-1887, п.4 теста: ручная строка на лимитной статье (как в + AURORA-1907) остаётся видимой на GET AHR с header.source='MANUAL', + is_over_limit=true — регрессия на «AURORA-1907 не трогаем» — одновременно + с тем, что лимитные (source_sheet='AHR_LIMIT') INPUT-строки той же статьи + остаются скрытыми.""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2052) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 1000 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["is_over_limit"] is True + assert manual_row["data"]["plan"]["q1"] == 1000 + + limit_input_rows = [ + row for row in ahr_rows + if row["row_type"] == "INPUT" + and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID + and row["data"]["header"].get("source") == "AHR_LIMIT" + ] + assert limit_input_rows == [], limit_input_rows + + item_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + item_plan = item_row["data"]["plan"] + assert item_plan["over_limit_year"] == 1000 + assert item_plan["year"] == item_plan["limit_year"] + item_plan["over_limit_year"] + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_can_add_row_false_for_satellite_statja_on_ahr(client, admin_tokens, auth_headers, sheet): + """AURORA-1887, п.5 теста: header.can_add_row=false для статьи + аренды/коммуналки/охраны на листе AHR — блокировка «Добавить строку» + (механизм — backend-флаг, читает фронт перед вызовом API; см. PR).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2053) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + header_row = _find_ahr_header_row(ahr_rows, expense_item_id) + assert header_row["data"]["header"]["can_add_row"] is False, header_row + + +def test_can_add_row_true_for_limit_and_ordinary_statja_on_ahr(client, admin_tokens, auth_headers): + """AURORA-1887: can_add_row=true для лимитной статьи (AURORA-1907 «сверх + лимита» не блокируем) и для обычной статьи без привязки к сателлитам — + регрессия (п.6 теста, часть 1: флаг читается верно до самой попытки + добавления).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2054) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + limit_header_row = _find_ahr_header_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID) + assert limit_header_row["data"]["header"]["can_add_row"] is True, limit_header_row + + ordinary_header_row = _find_ahr_header_row(ahr_rows, NO_LIMIT_EXPENSE_ITEM_ID) + assert ordinary_header_row["data"]["header"]["can_add_row"] is True, ordinary_header_row + + +def test_manual_line_add_still_works_for_ordinary_statja_on_ahr(client, admin_tokens, auth_headers): + """AURORA-1887, п.6 теста, часть 2: попытка добавить строку на обычной + статье без привязки к сателлитам (не лимит, не аренда/коммуналка/охрана) + — работает как раньше (регрессия, сквозной сценарий POST+PATCH+GET).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2055) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, NO_LIMIT_EXPENSE_ITEM_ID + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500 + ) + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["source"] == "MANUAL" + assert manual_row["data"]["header"]["can_add_row"] is True + assert manual_row["data"]["plan"]["q1"] == 500 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 f3d5186..99a867b 100644 --- a/api/tests/integration/test_form2_ahr_satellite_spod_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_spod_integration.py @@ -73,6 +73,19 @@ def _find_row(rows: list[dict], predicate) -> dict: raise AssertionError("строка не найдена") +def _find_ahr_header_row(rows: list[dict], expense_item_id: int) -> dict: + # AURORA-1887: INPUT-строка записи сателлита на листе AHR теперь скрыта + # (не выводится отдельной строкой) — заголовок статьи (ITEM/SUB_ITEM) + # остаётся видимым и несёт ту же агрегированную q4/totals-арифметику. + for row in rows: + if row.get("row_type") not in ("ITEM", "SUB_ITEM", "SUB_SUB_ITEM"): + continue + data = row.get("data") or {} + if (data.get("header") or {}).get("expense_item_id") == expense_item_id: + return row + raise AssertionError(f"заголовок статьи expense_item_id={expense_item_id} не найден: {rows}") + + @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 @@ -102,15 +115,17 @@ def test_satellite_spod_written_and_included_in_q4_total( def test_satellite_spod_syncs_into_ahr_support_quarter( client, admin_tokens, form2_id, sheet ): + """AURORA-1887: INPUT-строка записи сателлита на AHR теперь скрыта — + сравниваем приращение на заголовке статьи (ITEM/SUB_ITEM), а не + абсолютные значения (на статье уже могут быть другие записи/лимитные + строки из сидовых данных, тоже дающие вклад в actual_spod/fact_year).""" line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet) + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[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], - ) + ahr_row_before = _find_ahr_header_row(ahr_rows, expense_item_id) actual_quarter_before = ahr_row_before["data"]["q4"]["actual_quarter"] or 0 + actual_spod_before = ahr_row_before["data"]["q4"]["actual_spod"] or 0 fact_year_before = ahr_row_before["data"]["totals"]["fact_year"] or 0 response = client.patch( @@ -121,12 +136,8 @@ def test_satellite_spod_syncs_into_ahr_support_quarter( 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 + ahr_row_after = _find_ahr_header_row(ahr_rows, expense_item_id) + assert (ahr_row_after["data"]["q4"]["actual_spod"] or 0) - actual_spod_before == 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 diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 539e4b3..185c47d 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -18,19 +18,19 @@ import { SelectVspModal } from './Modals/SelectVspModal'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig'; import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; +import { useBudgetYear } from './hooks/useBudgetYear'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useTableScale } from './hooks/useTableScale'; -import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; -import { getRowId } from './utils/rowUtils'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; +import { canAddRowForHeader, getRowId } from './utils/rowUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; import { ROLES_NAME_ID } from '../../constants/constants'; -import { FormsNoCanAddRow } from './constants/formConfig'; import useDenomination from '../common/useDenomination'; +import { FormsNoCanAddRow } from './constants/formConfig'; const CONFIG_CACHE = new Map(); @@ -270,7 +270,16 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); + }, [ + columnsConfig, + columnsCurStage, + userRoleId, + handleCellUpdateError, + handleClickRowCell, + handleUpdateCell, + isCellInvalid, + denomination, + ]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет @@ -545,7 +554,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.log(match.columnId); handleNavigateToColumn(match.columnId); requestAnimationFrame(() => { - rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' }); + rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { + align: 'auto', + behavior: 'smooth', + }); }); return nextIndex; @@ -614,6 +626,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { toast.error('Выделите строку для вставки'); return; } + if (!canAddRowForHeader(row.original.data.header)) { + toast.error('Добавление строк недоступно для этой статьи'); + return; + } contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); }, [contextAddRow, getSelectedRow]); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index af5efcb..a70236e 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -52,3 +52,13 @@ export function isVspNewRow(rowData) { export function getParentId(data) { return data?.project_id || data?.expense_item_id || null; } + +// AURORA-1887: лист AHR отдаёт header.can_add_row=false для статей +// аренды/коммуналки/охраны (у записи сателлита нет понятия "сверх лимита" — +// ручная строка там не имеет смысла). На остальных листах и для остальных +// статей (включая лимитные — AURORA-1907 "сверх лимита" не трогаем) поле +// либо true, либо вовсе отсутствует/null — по умолчанию добавление строки +// разрешено. +export function canAddRowForHeader(header) { + return header?.can_add_row !== false; +} diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 0c40434..021463c 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy } from './rowUtils.js'; +import { buildRowHierarchy, canAddRowForHeader } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -19,3 +19,15 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']); assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []); }); + +// AURORA-1887: лист AHR блокирует «Добавить строку» для статей +// аренды/коммуналки/охраны через header.can_add_row=false; для всех прочих +// статей (включая лимитные, не трогаем AURORA-1907) и для листов, где это +// поле вовсе не считается (не AHR), добавление остаётся разрешённым. +test('canAddRowForHeader blocks only an explicit can_add_row=false', () => { + assert.equal(canAddRowForHeader({ can_add_row: false }), false); + assert.equal(canAddRowForHeader({ can_add_row: true }), true); + assert.equal(canAddRowForHeader({ can_add_row: null }), true); + assert.equal(canAddRowForHeader({}), true); + assert.equal(canAddRowForHeader(undefined), true); +}); -- 2.47.2