diff --git a/api/alembic/versions/0031_form2_ahr_satellite_sync.py b/api/alembic/versions/0031_form2_ahr_satellite_sync.py new file mode 100644 index 0000000..be388b0 --- /dev/null +++ b/api/alembic/versions/0031_form2_ahr_satellite_sync.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0031" +down_revision = "0030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0031_form2_ahr_satellite_sync.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/0032_empty_name_on_budget_line_create.py b/api/alembic/versions/0032_empty_name_on_budget_line_create.py new file mode 100644 index 0000000..a7d856b --- /dev/null +++ b/api/alembic/versions/0032_empty_name_on_budget_line_create.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0032" +down_revision = "0031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0032_empty_name_on_budget_line_create.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0031_form2_ahr_satellite_sync.sql b/api/alembic/versions/sql/0031_form2_ahr_satellite_sync.sql new file mode 100644 index 0000000..92a289d --- /dev/null +++ b/api/alembic/versions/sql/0031_form2_ahr_satellite_sync.sql @@ -0,0 +1,1322 @@ +-- Синхронизация сателлитных листов FORM_2 в основной лист АХР. +-- +-- Источник данных остается в сателлитах: +-- AHR_LIMIT, AHR_RENT, AHR_UTILITY, AHR_SECURITY. +-- Строки budget_line в основном листе АХР создаются/обновляются автоматически +-- и защищены от прямого редактирования через пользовательскую write-функцию. + +CREATE TABLE IF NOT EXISTS v3.form2_ahr_satellite_line_source ( + source_sheet character varying NOT NULL, + source_id integer NOT NULL, + target_line_id integer NOT NULL REFERENCES v3.budget_line(id) ON DELETE CASCADE, + PRIMARY KEY (source_sheet, source_id), + UNIQUE (target_line_id) +); + +CREATE OR REPLACE FUNCTION v3._ensure_form2_ahr_sync_line( + p_form_id integer, + p_expense_item_id integer +) +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 + ORDER BY bl.id + LIMIT 1; + + IF v_line_id IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES (p_form_id, p_expense_item_id, 'Support') + RETURNING id INTO v_line_id; + END IF; + + RETURN v_line_id; +END; +$function$; + +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 + ) + VALUES ( + p_form_id, p_expense_item_id, p_name, p_internal_order, p_vsp_id, 'Support' + ) + 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', + 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', + updated_at = now() + WHERE id = v_line_id; + END IF; + + RETURN v_line_id; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3._upsert_form2_ahr_sync_values( + p_form_id integer, + p_expense_item_id 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 +) +RETURNS void +LANGUAGE plpgsql +AS $function$ +DECLARE + v_line_id integer; +BEGIN + v_line_id := v3._ensure_form2_ahr_sync_line(p_form_id, p_expense_item_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) + VALUES + (v_line_id, 1, COALESCE(p_actual_jan, 0), COALESCE(p_actual_feb, 0), COALESCE(p_actual_mar, 0)), + (v_line_id, 2, COALESCE(p_actual_apr, 0), COALESCE(p_actual_may, 0), COALESCE(p_actual_jun, 0)), + (v_line_id, 3, COALESCE(p_actual_jul, 0), COALESCE(p_actual_aug, 0), COALESCE(p_actual_sep, 0)), + (v_line_id, 4, COALESCE(p_actual_oct, 0), COALESCE(p_actual_nov, 0), COALESCE(p_actual_dec, 0)) + ON CONFLICT (line_id, quarter) DO UPDATE SET + actual_m1 = EXCLUDED.actual_m1, + actual_m2 = EXCLUDED.actual_m2, + actual_m3 = EXCLUDED.actual_m3; +END; +$function$; + +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 +) +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) + VALUES + (v_line_id, 1, COALESCE(p_actual_jan, 0), COALESCE(p_actual_feb, 0), COALESCE(p_actual_mar, 0)), + (v_line_id, 2, COALESCE(p_actual_apr, 0), COALESCE(p_actual_may, 0), COALESCE(p_actual_jun, 0)), + (v_line_id, 3, COALESCE(p_actual_jul, 0), COALESCE(p_actual_aug, 0), COALESCE(p_actual_sep, 0)), + (v_line_id, 4, COALESCE(p_actual_oct, 0), COALESCE(p_actual_nov, 0), COALESCE(p_actual_dec, 0)) + ON CONFLICT (line_id, quarter) DO UPDATE SET + actual_m1 = EXCLUDED.actual_m1, + actual_m2 = EXCLUDED.actual_m2, + actual_m3 = EXCLUDED.actual_m3; +END; +$function$; + +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.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) + VALUES (p_form_id, p_expense_item_id, p_name, 'Support') + RETURNING id INTO v_line_id; + END IF; + + RETURN v_line_id; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3._upsert_form2_ahr_limit_sync_values( + p_form_id integer, + p_expense_item_id integer, + p_name character varying, + p_plan_q1 numeric DEFAULT 0, + p_plan_q2 numeric DEFAULT 0, + p_plan_q3 numeric DEFAULT 0, + p_plan_q4 numeric DEFAULT 0 +) +RETURNS void +LANGUAGE plpgsql +AS $function$ +DECLARE + v_line_id integer; +BEGIN + v_line_id := v3._ensure_form2_ahr_limit_sync_line( + p_form_id, + p_expense_item_id, + p_name + ); + + 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; +END; +$function$; + +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; + + -- AHR_LIMIT: каждая редактируемая LEAF-строка из справочника лимитов + -- становится отдельной budget_line в АХР. Строки одной статьи расходов + -- различаются по budget_line.name, например "Директор", + -- "Заместитель директора" и т.д. + 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, + COALESCE(fl.qty_q1, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q1, + COALESCE(fl.qty_q2, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q2, + COALESCE(fl.qty_q3, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q3, + COALESCE(fl.qty_q4, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) 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; + + -- AHR_RENT -> одна строка АХР на каждую строку rent_detail. + 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 + 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, 293, '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 + ); + + 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; + + -- AHR_UTILITY -> одна строка АХР на каждую строку utility_detail. + 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 + 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, 799, '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 + ); + + 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; + + -- AHR_SECURITY -> одна строка АХР на каждую строку security_detail. + 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 + 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, 31, '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 + ); + + 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$; + +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, + COALESCE(fl.qty_q1, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q1, + COALESCE(fl.qty_q2, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q2, + COALESCE(fl.qty_q3, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) AS plan_q3, + COALESCE(fl.qty_q4, 0) * COALESCE(lt.limit_with_vat, 0) * + (CASE WHEN lt.unit LIKE '%/год' THEN 0.25 + WHEN lt.unit LIKE '%/месяц' THEN 3 + ELSE 1 END) 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 + 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, 293, '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 + ); + + 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 + 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, 799, '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 + ); + + 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 + 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, 31, '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 + ); + + 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$; + +CREATE OR REPLACE FUNCTION v3.add_budget_form( + p_form_type character varying, + p_year integer, + p_org_unit_id integer DEFAULT NULL::integer, + p_user_id integer DEFAULT NULL::integer +) +RETURNS integer +LANGUAGE plpgsql +AS $function$ +DECLARE + v_form_id integer; + v_ssp_list jsonb := '[]'::jsonb; +BEGIN + IF p_user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id) THEN + RAISE EXCEPTION 'app_user id=% не существует', p_user_id; + END IF; + + IF p_org_unit_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN + RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id; + END IF; + + INSERT INTO v3.budget_form + (form_type_code, year, org_unit_id, created_by) + VALUES + (p_form_type, p_year, p_org_unit_id, p_user_id) + RETURNING id INTO v_form_id; + + PERFORM v3.copy_template_to_form(v_form_id); + + IF p_form_type = 'FORM_2' THEN + PERFORM v3.sync_form2_ahr_satellite_lines(v_form_id); + END IF; + + IF p_org_unit_id IS NOT NULL THEN + SELECT jsonb_build_array( + jsonb_build_object( + 'id', ou.id, + 'title', ou.title + ) + ) + INTO v_ssp_list + FROM v3.org_unit ou + WHERE ou.id = p_org_unit_id; + END IF; + + PERFORM v3.log_event( + 'CREATE_TASK', 'TASK', + jsonb_build_object( + 'form_type', p_form_type, + 'year', p_year, + 'org_unit_id', p_org_unit_id, + 'ssp_list', COALESCE(v_ssp_list, '[]'::jsonb), + 'entity_id', v_form_id + ), + v_form_id, null, p_org_unit_id + ); + + RETURN v_form_id; +END; +$function$; + +CREATE OR REPLACE FUNCTION v3.form2_ahr_satellite_locked_columns() +RETURNS text[] +LANGUAGE sql +IMMUTABLE +AS $function$ + SELECT ARRAY[ + 'plan.q1', 'plan.q2', 'plan.q3', 'plan.q4', 'plan.year', + 'q1.actual_m1', 'q1.actual_m2', 'q1.actual_m3', 'q1.actual_quarter', + 'q2.actual_m1', 'q2.actual_m2', 'q2.actual_m3', 'q2.actual_quarter', + 'q3.actual_m1', 'q3.actual_m2', 'q3.actual_m3', 'q3.actual_quarter', + 'q4.actual_m1', 'q4.actual_m2', 'q4.actual_m3', 'q4.actual_quarter', + 'totals.fact_year' + ]::text[]; +$function$; + +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.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$; + +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 EXISTS ( + SELECT 1 + FROM v3.budget_form bf + JOIN v3.budget_line bl ON bl.budget_form_id = bf.id + WHERE bf.id = p_form_id + AND bf.form_type_code = 'FORM_2' + AND bl.id = p_line_id + AND ( + EXISTS ( + SELECT 1 + FROM v3.form2_ahr_satellite_line_source m + WHERE m.target_line_id = bl.id + ) + OR EXISTS ( + SELECT 1 + FROM v3.limit_template lt + JOIN v3.expense_item ei + ON ei.id = bl.expense_item_id + 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 + ) + 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 bl.name = regexp_replace( + lt.name, + '^[[:space:]]*-[[:space:]]*', + '' + ) + ) + ) + ); +$function$; + +CREATE OR REPLACE FUNCTION v3.upd_form_cells( + p_form_id integer, + p_sheet character varying, + p_changes jsonb, + p_user_id integer, + p_direction character varying DEFAULT NULL::character varying, + p_sections text[] DEFAULT NULL::text[] +) +RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) +LANGUAGE plpgsql +AS $function$ +DECLARE + v_change jsonb; + v_column text; + v_line_id integer; + v_decision jsonb; + v_msg text; +BEGIN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'role_not_allowed: '; + END IF; + + IF NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id AND is_active) THEN + RAISE EXCEPTION 'role_not_allowed: unknown or inactive user %', p_user_id; + END IF; + + IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN + RAISE EXCEPTION 'p_changes must be a JSONB array'; + END IF; + + FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes) + LOOP + v_column := v_change->>'column'; + v_line_id := (v_change->>'line_id')::integer; + + IF v_column IS NULL THEN + RAISE EXCEPTION 'change must have column: %', v_change; + END IF; + + IF p_sheet = 'AHR' + AND v3.is_form2_ahr_satellite_line(p_form_id, v_line_id) THEN + RAISE EXCEPTION 'computed_field: % (значение синхронизируется из сателлитного листа)', v_column; + END IF; + + v_decision := v3.can_edit(p_form_id, p_sheet, p_direction, v_column, p_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: %', v_column; + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: %', v_column; + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: %s (opens %s)', + v_column, v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: %s (closed %s)', + v_column, v_decision->>'closes_at'); + ELSE + v_msg := format('window_closed: %s (no phase)', v_column); + END IF; + RAISE EXCEPTION '%', v_msg; + ELSE + RAISE EXCEPTION '%: %', v_decision->>'code', v_column; + END IF; + END IF; + END LOOP; + + RETURN QUERY + SELECT * FROM v3.upd_form_cells( + p_form_id, p_sheet, p_changes, p_direction, p_sections + ); + + IF p_sheet IN ('AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY') THEN + FOR v_line_id IN + SELECT DISTINCT (change->>'line_id')::integer + FROM jsonb_array_elements(p_changes) AS change + WHERE change ? 'line_id' + LOOP + PERFORM v3.sync_form2_ahr_satellite_line(p_form_id, p_sheet, v_line_id); + END LOOP; + END IF; +END; +$function$; + +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; +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 293 -- Аренда_ОН + WHEN 'AHR_UTILITY' THEN 799 -- Коммунальные услуги + WHEN 'AHR_SECURITY' THEN 31 -- Охрана + 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; + + SELECT id INTO v_parent_line + FROM v3.budget_line + WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid + LIMIT 1; + IF v_parent_line IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES ( + p_form_id, v_sat_eid, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + END IF; + + 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; + + 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, p_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', p_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$; + +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[]; +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 + 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 IF; + RETURN; + END IF; + + SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id + INTO v_form_id, v_sheet, v_eid + 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; + + 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$; + +-- В существующих данных могут быть строки budget_line, вставленные с явными id, +-- при этом serial sequence может отставать от MAX(id). Выравниваем sequence +-- перед backfill, который создает сгенерированные строки АХР. +DO $do$ +DECLARE + v_sequence_name text; +BEGIN + v_sequence_name := pg_get_serial_sequence('v3.budget_line', 'id'); + + IF v_sequence_name IS NOT NULL THEN + PERFORM setval( + v_sequence_name, + COALESCE((SELECT MAX(id) FROM v3.budget_line), 0), + true + ); + END IF; +END; +$do$; + +-- Повторно синхронизируем существующие формы FORM_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/alembic/versions/sql/0032_empty_name_on_budget_line_create.sql b/api/alembic/versions/sql/0032_empty_name_on_budget_line_create.sql new file mode 100644 index 0000000..70c2fa3 --- /dev/null +++ b/api/alembic/versions/sql/0032_empty_name_on_budget_line_create.sql @@ -0,0 +1,212 @@ +-- Новые пользовательские строки создаются с пустым наименованием. +-- Заполнение header.name остается через обычную проверку этапов/ролей: +-- v3.can_edit(..., 'header.name', ...). +-- Сателлитные листы AHR_RENT/AHR_UTILITY/AHR_SECURITY оставлены в прежней логике. +-- Используем пустую строку, а не NULL: представления формы показывают +-- справочное имя статьи через COALESCE(budget_line.name, expense_item.name). + +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; +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 293 + WHEN 'AHR_UTILITY' THEN 799 + WHEN 'AHR_SECURITY' THEN 31 + 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; + + SELECT id INTO v_parent_line + FROM v3.budget_line + WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid + LIMIT 1; + IF v_parent_line IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES ( + p_form_id, v_sat_eid, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + END IF; + + 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; + + 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, '', 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', '', + '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$; diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index bb4be5d..a892153 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -9,7 +9,7 @@ import time from typing import Any, Optional # -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import SQLAlchemyError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession @@ -24,8 +24,8 @@ from src.db.models.form_type import FormTypeEnum from src.services.budget_form_service import BudgetFormService from src.services.project_service import ProjectService from src.services.sheet_service import SheetService -from src.core.errors import BasicAppException, ValidationsError -from src.core.exception_handlers import map_sqlalchemy_error +from src.core.errors import BasicAppException, ValidationsError +from src.core.exception_handlers import map_sqlalchemy_error from src.db.session import SessionLocal from src.services.user_service import UserService @@ -33,6 +33,13 @@ from src.services.user_service import UserService logger = logging.getLogger() +FORM_2_AHR_SATELLITE_SHEETS = { + "AHR_LIMIT", + "AHR_RENT", + "AHR_UTILITY", + "AHR_SECURITY", +} + @asynccontextmanager async def get_db_session(user_id: int | None = None): @@ -215,6 +222,27 @@ class ConnectionManager: except: pass # Игнорируем недоступные соединения + async def broadcast_to_form_sheet( + self, + data: dict, + form_id: int, + sheet: str, + ): + data = data.copy() + data["is_self"] = None + message = dumps(data, default=_convert_error, ensure_ascii=False) + + for key, connections in self.connections[ConnectionKeyEnum.FORM].items(): + key_data = dict(key) + if key_data.get("form_id") != form_id or key_data.get("sheet") != sheet: + continue + + for con_info in connections: + try: + await con_info.ws.send_text(message) + except: + pass # Игнорируем недоступные соединения + async def send_back(self, data: dict, websocket: WebSocket): data = data.copy() data["is_self"] = True @@ -389,26 +417,26 @@ class FormEventProcess: event_data=event_data["data"], user=curr_user, ) - case "program_updated": - return await self.__update_form4_program( - event_data=event_data["data"], - user=curr_user, - ) - case "program_deleted": - return await self.__delete_form4_program( - event_data=event_data["data"], - user=curr_user, - ) - case "project_updated": - return await self.__update_form4_project( - event_data=event_data["data"], - user=curr_user, - ) - case "project_deleted": - return await self.__delete_form4_project( - event_data=event_data["data"], - user=curr_user, - ) + case "program_updated": + return await self.__update_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "program_deleted": + return await self.__delete_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "project_updated": + return await self.__update_form4_project( + event_data=event_data["data"], + user=curr_user, + ) + case "project_deleted": + return await self.__delete_form4_project( + event_data=event_data["data"], + user=curr_user, + ) case _: pass @@ -530,48 +558,48 @@ class FormEventProcess: program_id=event_data["program_id"], user=user, ) - - async def __update_form4_program( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.update_form4_program( - program_id=event_data["program_id"], - name=event_data["name"], - user=user, - ) - - async def __delete_form4_program( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.delete_form4_program( - program_id=event_data["program_id"], - user=user, - ) - - async def __update_form4_project( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.update_form4_project( - project_id=event_data["project_id"], - name=event_data["name"], - user=user, - ) - - async def __delete_form4_project( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.delete_form4_project( - project_id=event_data["project_id"], - user=user, - ) + + async def __update_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_program( + program_id=event_data["program_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_program( + program_id=event_data["program_id"], + user=user, + ) + + async def __update_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_project( + project_id=event_data["project_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_project( + project_id=event_data["project_id"], + user=user, + ) async def __del_row( self, @@ -739,7 +767,94 @@ class ProjectEventProcess: ) -manager = ConnectionManager() +manager = ConnectionManager() + + +def get_satellite_source_row_id(data: dict) -> int | None: + event_name = data.get("event") + + if event_name == "cell_updated": + return data.get("data", {}).get("line_id") + + if event_name == "row_deleted": + return data.get("data", {}).get("row_id") + + if event_name == "row_added": + result = data.get("result") + if not isinstance(result, dict): + return None + + rows = result.get("data") or [] + for row in rows: + if len(row) >= 4 and isinstance(row[3], dict) and row[3].get("id") is not None: + return row[3]["id"] + + return None + + +async def notify_ahr_about_satellite_change( + form_id: int, + source_sheet: str, + source_row_id: int, + source_event: str, + user_id: int, +): + try: + async with get_db_session(user_id=user_id) as db: + ahr_delta = await SheetService(db).get_form2_ahr_satellite_delta( + form_id=form_id, + source_sheet=source_sheet, + source_row_id=source_row_id, + ) + + if not ahr_delta: + return + + ahr_event = { + "event": "cell_updated", + "data": { + "source_sheet": source_sheet, + }, + "result": ahr_delta, + } + + if source_event == "row_added": + new_row = next( + ( + row for row in ahr_delta + if row[0] == "INPUT" + ), + None, + ) + if new_row is not None: + new_line_id = new_row[3].get("line_id") + ahr_event = { + "event": "row_added", + "data": { + "source_sheet": source_sheet, + "expense_item_id": new_row[3].get("header", {}).get("expense_item_id"), + }, + "result": { + "data": ahr_delta, + "new_line_id": new_line_id, + }, + } + + await manager.broadcast_to_form_sheet( + data=ahr_event, + form_id=form_id, + sheet="AHR", + ) + except Exception: + logger.exception( + "Ошибка отправки на лист АХР", + extra={ + "form_id": form_id, + "source_sheet": source_sheet, + "source_row_id": source_row_id, + "source_event": source_event, + }, + ) def _convert_error(o): @@ -910,7 +1025,17 @@ async def process_websocket( user_id=user_id, **kwargs_process ) - await db.commit() + should_refresh_ahr = ( + con_key == ConnectionKeyEnum.FORM + and kwargs_process.get("sheet") in FORM_2_AHR_SATELLITE_SHEETS + and data.get("event") in {"cell_updated", "row_added", "row_deleted"} + ) + ahr_source_row_id = ( + get_satellite_source_row_id(data) + if should_refresh_ahr + else None + ) + await db.commit() db_ms = (time.perf_counter() - t0) * 1000 extra = { @@ -936,10 +1061,10 @@ async def process_websocket( "row_added", "program_added", "project_added", - "program_updated", - "project_updated", - "program_deleted", - "project_deleted", + "program_updated", + "project_updated", + "program_deleted", + "project_deleted", "row_deleted", ]: await manager.broadcast_to_all( @@ -956,23 +1081,34 @@ async def process_websocket( **kwargs, ) + if should_refresh_ahr and ahr_source_row_id is not None: + asyncio.create_task( + notify_ahr_about_satellite_change( + form_id=kwargs_process["form_id"], + source_sheet=kwargs_process["sheet"], + source_row_id=ahr_source_row_id, + source_event=data["event"], + user_id=user_id, + ) + ) + except BasicAppException as e: data["error"] = e.description or "Неизвестная ошибка" await manager.send_back( data=data, websocket=websocket, ) - except SQLAlchemyError as e: - _, code, message, field = map_sqlalchemy_error(e) - data["error"] = { - "code": code, - "field": field, - "message": message, - } - await manager.send_back( - data=data, - websocket=websocket, - ) + except SQLAlchemyError as e: + _, code, message, field = map_sqlalchemy_error(e) + data["error"] = { + "code": code, + "field": field, + "message": message, + } + await manager.send_back( + data=data, + websocket=websocket, + ) except SheetValidationError as e: data["error"] = e.message await manager.send_back( diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index e1cde17..834cf84 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -380,3 +380,31 @@ class SheetRepository: ) ).all() ] + + async def get_form2_ahr_satellite_delta( + self, + form_id: int, + source_sheet: str, + source_row_id: int, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.get_form2_ahr_satellite_delta( + :form_id, + :source_sheet, + :source_row_id + ) + """ + ), + { + "form_id": form_id, + "source_sheet": source_sheet, + "source_row_id": source_row_id, + }, + ) + ).all() + ] diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 110f33e..51bdab9 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -274,3 +274,15 @@ class SheetService: row_id=row_id, direction=direction, ) + + async def get_form2_ahr_satellite_delta( + self, + form_id: int, + source_sheet: str, + source_row_id: int, + ) -> list[tuple]: + return await self.sheet_repo.get_form2_ahr_satellite_delta( + form_id=form_id, + source_sheet=source_sheet, + source_row_id=source_row_id, + )