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