-- ════════════════════════════════════════════════════════════════════════════ -- migrate_form4_project.sql — выделение FORM_4 программ/проектов в отдельную -- сущность v3.form4_project (перестают делить таблицу с FORM_3 v3.project). -- ════════════════════════════════════════════════════════════════════════════ -- Мотивация: v3.project исторически был FORM_4-таблицей (program/project + -- parent_id), FORM_3-редизайн навесил сверху свои атрибуты (project_type, -- vsp_format, address, area, staff, org_unit_id) и строгий chk_v3_project_name. -- Два разных концепта под одним словом «project» → разреженные колонки, нет -- защиты FK, констрейнт вынужден ветвиться по «виду» строки. Разводим. -- -- Новая таблица v3.form4_project — точный костяк без FORM_3-полей: -- program (level='program', parent_id=NULL) -- project (level='project', parent_id=program) -- -- Дискриминатор FORM_4 vs FORM_3: FORM_3-строки — те, что ссылаются из -- v3.rf_project_report (add_project всегда создаёт отчёты для КАЖДОЙ строки, -- включая программы). Всё остальное в v3.project — FORM_4. -- -- Порядок: после init.sql + всех migrate_form3*/migrate_to_identity (нужна -- финальная форма v3.project с org_unit_id). Один прогон — copy защищён -- проверкой на пустоту form4_project. -- ════════════════════════════════════════════════════════════════════════════ -- ───── 1. Новая таблица ───────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS v3.form4_project ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, level VARCHAR CHECK (level IN ('program', 'project')), section_code VARCHAR, parent_id INTEGER REFERENCES v3.form4_project(id), -- org_unit_id INTEGER REFERENCES v3.org_unit(id), form_id INTEGER REFERENCES v3.budget_form(id), -- project (level='project') обязан иметь родителя-программу; program — нет. CONSTRAINT chk_form4_project_parent CHECK ( (level = 'program' AND parent_id IS NULL) OR (level = 'project') ), CONSTRAINT chk_form4_project_section_code CHECK ( (level = 'program') = (section_code IS NOT NULL) ) ); CREATE INDEX ix_form4_project_section_code ON v3.form4_project(section_code) WHERE level = 'program'; CREATE INDEX IF NOT EXISTS ix_v3_form4_project_parent ON v3.form4_project(parent_id); CREATE INDEX IF NOT EXISTS ix_v3_form4_project_form ON v3.form4_project(form_id); -- ───── 2. Перенос FORM_4-строк из v3.project (с сохранением id) ────────────── -- FK budget_line.project_id ссылается по id → id сохраняем 1:1. -- Копируем обе роли (program + project) одним INSERT: self-FK parent_id внутри -- одного оператора проверяется в конце, порядок строк не важен. -- DO $migrate$ -- BEGIN -- IF NOT EXISTS (SELECT 1 FROM v3.form4_project) THEN -- INSERT INTO v3.form4_project (id, name, level, parent_id, org_unit_id) -- SELECT p.id, p.name, p.level, p.parent_id, (p.org_unit_id -- FROM v3.project p -- WHERE p.id NOT IN ( -- SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL -- ); -- -- Синхронизируем sequence, чтобы новые INSERT не конфликтовали по id. -- PERFORM setval('v3.form4_project_id_seq', -- GREATEST((SELECT COALESCE(MAX(id), 0) FROM v3.form4_project), 1)); -- RAISE NOTICE 'form4_project: перенесено % строк из v3.project', -- (SELECT count(*) FROM v3.form4_project); -- ELSE -- RAISE NOTICE 'form4_project уже наполнена — перенос пропущен'; -- END IF; -- END -- $migrate$; -- ───── 3. Перецепка FK budget_line.project_id на новую таблицу ────────────── ALTER TABLE v3.budget_line DROP CONSTRAINT IF EXISTS budget_line_project_id_fkey; ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_project_id_fkey FOREIGN KEY (project_id) REFERENCES v3.form4_project(id); -- ───── 4. Удаление перенесённых строк из v3.project ───────────────────────── -- После перецепки budget_line на v3.project больше не ссылается ничего FORM_4. -- Остаются только FORM_3-строки (referenced by rf_project_report). DELETE FROM v3.project p WHERE p.id NOT IN ( SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL ); -- ───── 5. Восстановление chk_v3_project_name в строгой форме ───────────────── -- FORM_4-строки ушли — v3.project теперь чисто FORM_3, правило коротких имён -- ВСП применимо ко всей таблице (как до form4_write.sql). NOT VALID сохраняем. ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS chk_v3_project_name; ALTER TABLE v3.project ADD CONSTRAINT chk_v3_project_name CHECK (name IS NULL OR ( length(name) <= 30 AND name !~ E'[+\\-/\\\\=&* ]' )) NOT VALID; -- ════════════════════════════════════════════════════════════════════════════ -- v3.add_form4_program / v3.add_form4_project — создание программ и проектов -- FORM_4 в выделенной таблице v3.form4_project (иерархия program → project). -- ════════════════════════════════════════════════════════════════════════════ -- FORM_4 хранит иерархию отдельно от FORM_3 (v3.project) — см. -- migrate_form4_project.sql. Костяк: -- program (level='program', parent_id=NULL) → depth=1 (GROUP) в v_form4 -- project (level='project', parent_id=program) → depth=2 (ITEM) в v_form4 -- Строки бюджета привязываются к проекту через budget_line.project_id. -- -- Никаких FORM_3-атрибутов / rf_project_report / snapshot фаз — это ветка -- budget_form. p_org_unit_id опционален (read-path FORM_4 по нему не фильтрует, -- заполняется для аудита/принадлежности). -- -- Порядок применения: после migrate_form4_project.sql (нужна таблица -- v3.form4_project) и audit.sql (нужна v3.log_event). -- ════════════════════════════════════════════════════════════════════════════ -- ───── 1. add_form4_program ───────────────────────────────────────────────── DROP FUNCTION IF EXISTS v3.add_form4_program(VARCHAR, INT, VARCHAR, INT); CREATE FUNCTION v3.add_form4_program( p_name VARCHAR, p_form_id INT, p_section_code VARCHAR DEFAULT NULL ) RETURNS TABLE ( project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB ) AS $function$ #variable_conflict use_column DECLARE v_pid INT; BEGIN IF p_name IS NULL OR length(trim(p_name)) = 0 THEN RAISE EXCEPTION 'p_name обязателен'; END IF; IF p_form_id IS NULL THEN RAISE EXCEPTION 'p_form_id обязателен'; END IF; IF NOT EXISTS ( SELECT 1 FROM v3.budget_form bf WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id ) THEN RAISE EXCEPTION 'Только FORM_4'; END IF; IF p_section_code IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM v3.expense_item WHERE section_code = p_section_code AND depth = 0 ) THEN RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code; END IF; INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) VALUES (p_name, 'program', NULL, p_form_id, p_section_code) RETURNING id INTO v_pid; PERFORM v3.log_event( 'PROJECT_CREATE', 'PROJECT', jsonb_strip_nulls(jsonb_build_object( 'project_id', v_pid, 'name', p_name, 'level', 'program', 'form_id', p_form_id, 'form_type', 'FORM_4' )), p_form_id, NULL, NULL ); RETURN QUERY SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid) OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code) ORDER BY v.sort_order; END; $function$ LANGUAGE plpgsql VOLATILE; -- ───── 2. add_form4_project ───────────────────────────────────────────────── DROP FUNCTION IF EXISTS v3.add_form4_project(VARCHAR, INT); CREATE FUNCTION v3.add_form4_project( p_name VARCHAR, p_program_id INT ) RETURNS TABLE ( project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB ) AS $function$ #variable_conflict use_column DECLARE v_pid INT; v_section_code VARCHAR; v_form_id INT; BEGIN IF p_name IS NULL OR length(trim(p_name)) = 0 THEN RAISE EXCEPTION 'p_name обязателен'; END IF; IF p_program_id IS NULL THEN RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)'; END IF; IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp WHERE fp.id = p_program_id AND fp.level = 'program') THEN RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id; END IF; SELECT form_id INTO v_form_id FROM v3.form4_project WHERE id = p_program_id; INSERT INTO v3.form4_project (name, level, parent_id, form_id) VALUES (p_name, 'project', p_program_id, v_form_id) RETURNING id INTO v_pid; PERFORM v3.log_event( 'PROJECT_CREATE', 'PROJECT', jsonb_strip_nulls(jsonb_build_object( 'project_id', v_pid, 'name', p_name, 'level', 'project', 'parent_id', p_program_id, 'form_id', v_form_id, 'form_type', 'FORM_4' )), v_form_id, NULL, NULL ); SELECT fp.section_code INTO v_section_code FROM v3.form4_project fp WHERE fp.id = p_program_id AND fp.level = 'program'; IF v_section_code IS NOT NULL THEN RETURN QUERY SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid) OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) ORDER BY v.sort_order; END IF; END; $function$ LANGUAGE plpgsql VOLATILE; -- ════════════════════════════════════════════════════════════════════════════ -- v3.v_form4_sheet_jsonb — JSONB-выдача FORM_4 для АХР_Р / КВ_Р / Операц -- ════════════════════════════════════════════════════════════════════════════ -- Иерархия (4 уровня): -- ROOT depth=0 — section_code (1.00.0/3.00.0/4.00.0/5.00.0) -- PROGRAM depth=1 — v3.form4_project level=program -- PROJECT depth=2 — v3.form4_project level=project (parent_id=program) -- INPUT depth=3 — budget_line (R-код) -- -- Финансовые блоки (plan, seq_dfip, approved, booking, q1-q4, totals) -- агрегируются на уровнях ROOT/PROGRAM/PROJECT через v3.jsonb_numeric_sum. -- Текстовые поля (counterparty, comment, justification…) на агрегатных -- уровнях NULL'ятся (jsonb_numeric_sum их игнорирует). -- -- p_form_id — id формы FORM_4 -- p_sheet — 'AHR' / 'CAP' / 'OPER' -- p_sections — массив включаемых блоков (как в v_form4_sheet_lines_jsonb) -- ════════════════════════════════════════════════════════════════════════════ -- ───── 1. Хелпер: рекурсивная сумма численных листьев двух JSONB ────────── CREATE OR REPLACE FUNCTION v3.jsonb_numeric_sum(a JSONB, b JSONB) RETURNS JSONB LANGUAGE sql IMMUTABLE AS $function$ SELECT CASE WHEN a IS NULL THEN b WHEN b IS NULL THEN a WHEN jsonb_typeof(a) = 'number' AND jsonb_typeof(b) = 'number' THEN to_jsonb((a)::text::numeric + (b)::text::numeric) WHEN jsonb_typeof(a) = 'object' AND jsonb_typeof(b) = 'object' THEN ( SELECT jsonb_object_agg(k, v3.jsonb_numeric_sum(a -> k, b -> k) ) FROM ( SELECT k FROM jsonb_object_keys(a) k UNION SELECT k FROM jsonb_object_keys(b) k ) keys ) -- одна сторона число/объект, другая нет → берём числовую/объектную WHEN jsonb_typeof(a) IN ('number','object') THEN a WHEN jsonb_typeof(b) IN ('number','object') THEN b ELSE NULL END; $function$; DROP AGGREGATE IF EXISTS v3.jsonb_sum(JSONB); CREATE AGGREGATE v3.jsonb_sum(JSONB) ( SFUNC = v3.jsonb_numeric_sum, STYPE = JSONB ); -- ───── 2. Основная функция ───────────────────────────────────────────────── CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb( 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$ -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ WITH inp_raw AS ( SELECT j.* FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j WHERE j.row_type = 'INPUT' ), inp AS ( SELECT i.*, bl.project_id AS line_project_id, -- --- разрешение проекта --- -- A. bl.project_id → project (level='project') -- B. bl.project_id → program (level='program') — тогда проекта нет CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id, CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name, -- --- разрешение программы --- -- A. через проект: prog_via_prj -- B. напрямую: prog_direct COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id, COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name, -- --- section (depth=0) --- sec.id AS sec_id, sec.section_code AS sec_code, sec.name AS sec_name FROM inp_raw i JOIN v3.budget_line bl ON bl.id = i.line_id -- проект (level='project') LEFT JOIN v3.form4_project prj ON prj.id = bl.project_id AND prj.level = 'project' -- программа через проект LEFT JOIN v3.form4_project prog_via_prj ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program' -- программа напрямую (bl.project_id → program) LEFT JOIN v3.form4_project prog_direct ON prog_direct.id = bl.project_id AND prog_direct.level = 'program' -- раздел depth=0 (через expense_item) JOIN v3.expense_item ei ON ei.id = bl.expense_item_id LEFT JOIN LATERAL ( WITH RECURSIVE up AS ( SELECT id, parent_id, name, section_code, depth FROM v3.expense_item WHERE id = ei.id UNION ALL SELECT e.id, e.parent_id, e.name, e.section_code, e.depth FROM v3.expense_item e JOIN up u ON u.parent_id = e.id ) SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1 ) sec ON TRUE ), -- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════ all_programs AS ( SELECT fp.id AS prog_id, fp.name AS prog_name, fp.section_code, sec.id AS sec_id, sec.section_code AS sec_code, sec.name AS sec_name FROM v3.form4_project fp LEFT JOIN LATERAL ( SELECT id, section_code, name FROM v3.expense_item WHERE section_code = fp.section_code AND depth = 0 LIMIT 1 ) sec ON TRUE WHERE fp.level = 'program' AND (p_sections IS NULL OR fp.section_code = ANY(p_sections)) AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id ), -- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════ all_projects AS ( SELECT prj.id AS prj_id, prj.name AS prj_name, prj.parent_id AS prog_id, ap.sec_id, ap.sec_code, ap.sec_name FROM v3.form4_project prj JOIN all_programs ap ON ap.prog_id = prj.parent_id WHERE prj.level = 'project' AND (ap.section_code = p_sheet) ), -- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════ -- PROJECT-агрегаты prj_agg AS ( SELECT sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, prog_id, MAX(prog_name) AS prog_name, prj_id, MAX(prj_name) AS prj_name, v3.jsonb_sum(plan_data) AS plan_data, v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, v3.jsonb_sum(approved_data) AS approved_data, v3.jsonb_sum(contract_summary_data) AS contract_summary_data, v3.jsonb_sum(allocation_data) AS allocation_data, v3.jsonb_sum(reserve_data) AS reserve_data, v3.jsonb_sum(collegial_data) AS collegial_data, v3.jsonb_sum(ckk_data) AS ckk_data, v3.jsonb_sum(contract_data) AS contract_data, v3.jsonb_sum(booking_data) AS booking_data, v3.jsonb_sum(q1_data) AS q1_data, v3.jsonb_sum(q2_data) AS q2_data, v3.jsonb_sum(q3_data) AS q3_data, v3.jsonb_sum(q4_data) AS q4_data, v3.jsonb_sum(totals_data) AS totals_data FROM inp WHERE prj_id IS NOT NULL GROUP BY sec_id, prog_id, prj_id ), -- PROGRAM-агрегаты prog_agg AS ( SELECT sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, prog_id, MAX(prog_name) AS prog_name, v3.jsonb_sum(plan_data) AS plan_data, v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, v3.jsonb_sum(approved_data) AS approved_data, v3.jsonb_sum(contract_summary_data) AS contract_summary_data, v3.jsonb_sum(allocation_data) AS allocation_data, v3.jsonb_sum(reserve_data) AS reserve_data, v3.jsonb_sum(collegial_data) AS collegial_data, v3.jsonb_sum(ckk_data) AS ckk_data, v3.jsonb_sum(contract_data) AS contract_data, v3.jsonb_sum(booking_data) AS booking_data, v3.jsonb_sum(q1_data) AS q1_data, v3.jsonb_sum(q2_data) AS q2_data, v3.jsonb_sum(q3_data) AS q3_data, v3.jsonb_sum(q4_data) AS q4_data, v3.jsonb_sum(totals_data) AS totals_data FROM inp WHERE prog_id IS NOT NULL GROUP BY sec_id, prog_id ), -- ROOT-агрегаты root_agg AS ( SELECT sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, v3.jsonb_sum(plan_data) AS plan_data, v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, v3.jsonb_sum(approved_data) AS approved_data, v3.jsonb_sum(contract_summary_data) AS contract_summary_data, v3.jsonb_sum(allocation_data) AS allocation_data, v3.jsonb_sum(reserve_data) AS reserve_data, v3.jsonb_sum(collegial_data) AS collegial_data, v3.jsonb_sum(ckk_data) AS ckk_data, v3.jsonb_sum(contract_data) AS contract_data, v3.jsonb_sum(booking_data) AS booking_data, v3.jsonb_sum(q1_data) AS q1_data, v3.jsonb_sum(q2_data) AS q2_data, v3.jsonb_sum(q3_data) AS q3_data, v3.jsonb_sum(q4_data) AS q4_data, v3.jsonb_sum(totals_data) AS totals_data FROM inp GROUP BY sec_id ), -- Нулевой JSONB-шаблон (для пустых программ/проектов) zero AS ( SELECT '{}'::jsonb AS z ), -- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════ unioned AS ( -- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк) SELECT 'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id, jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header, plan_data, seq_dfip_data, approved_data, contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, contract_data, booking_data, q1_data, q2_data, q3_data, q4_data, totals_data, ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path FROM root_agg UNION ALL -- GROUP — из агрегатов (программы с бюджетными строками) SELECT 'GROUP', 1, NULL, jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), plan_data, seq_dfip_data, approved_data, contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, contract_data, booking_data, q1_data, q2_data, q3_data, q4_data, totals_data, ARRAY[sec_id, prog_id, 0, 0]::INT[] FROM prog_agg UNION ALL -- GROUP — пустые программы (нет ни одной budget_line) SELECT 'GROUP', 1, NULL, jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), NULL::jsonb, NULL::jsonb, NULL::jsonb, -- plan, seq_dfip, approved NULL::jsonb, NULL::jsonb, NULL::jsonb, -- contract_summary, allocation, reserve NULL::jsonb, NULL::jsonb, -- collegial, ckk NULL::jsonb, NULL::jsonb, -- contract, booking NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, -- q1-q4, totals ARRAY[sec_id, prog_id, 0, 0]::INT[] FROM all_programs ap WHERE NOT EXISTS ( SELECT 1 FROM v3.budget_line bl WHERE bl.budget_form_id = p_form_id AND ( bl.project_id = ap.prog_id -- прямая привязка к программе OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект WHERE pr.parent_id = ap.prog_id AND pr.level = 'project') ) ) UNION ALL -- ITEM — из агрегатов (проекты с бюджетными строками) SELECT 'ITEM', 2, NULL, jsonb_build_object('section_code', sec_code, 'name', prj_name, 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), plan_data, seq_dfip_data, approved_data, contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, contract_data, booking_data, q1_data, q2_data, q3_data, q4_data, totals_data, ARRAY[sec_id, prog_id, prj_id, 0]::INT[] FROM prj_agg UNION ALL -- ITEM — пустые проекты (нет budget_line) SELECT 'ITEM', 2, NULL, jsonb_build_object('section_code', sec_code, 'name', prj_name, 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, ARRAY[sec_id, prog_id, prj_id, 0]::INT[] FROM all_projects apr WHERE NOT EXISTS ( SELECT 1 FROM v3.budget_line bl WHERE bl.budget_form_id = p_form_id AND bl.project_id = apr.prj_id ) UNION ALL -- INPUT — все строки бюджета SELECT 'INPUT', 3, line_id, header || jsonb_build_object( 'project_id', prj_id, 'project_name', prj_name, 'program_id', prog_id, 'program_name', prog_name ), plan_data, seq_dfip_data, approved_data, contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, contract_data, booking_data, q1_data, q2_data, q3_data, q4_data, totals_data, ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(prj_id, 999999), line_id]::INT[] FROM inp ) SELECT u.row_type, u.depth, ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order, jsonb_build_object( 'line_id', u.line_id, 'header', u.header, 'plan', u.plan_data, 'seq_dfip', u.seq_dfip_data, 'approved', u.approved_data, 'contract_summary', u.contract_summary_data, 'allocation', u.allocation_data, 'reserve', u.reserve_data, 'collegial', u.collegial_data, 'ckk', u.ckk_data, 'contract', u.contract_data, 'booking', u.booking_data, 'q1', u.q1_data, 'q2', u.q2_data, 'q3', u.q3_data, 'q4', u.q4_data, 'totals', u.totals_data ) AS data FROM unioned u ORDER BY u.sort_path; $function$ LANGUAGE sql STABLE; -- ════════════════════════════════════════════════════════════════════════════ -- v3.add_budget_line — создать новую INPUT-строку (budget_line) для FORM_1/2/4. -- -- Способы передать expense_item: -- путь A — численный id: p_expense_item_id (берётся из header.expense_item_id) -- путь B — резолв по коду: p_sheet + p_item_id [+ p_section_code для коллизий] -- [+ p_direction для FORM_1] -- -- Авто-direction: -- FORM_2 → 'Support' -- FORM_4 → 'Development' -- FORM_1/OPER → NULL -- FORM_1/AHR|CAP → p_direction обязателен (Support|Development) -- Если p_direction передан и противоречит авто-значению — ошибка. -- -- Дубликаты на одном expense_item разрешены (UNIQUE не накладывается). -- vsp_id опционален — для FORM_2 фронт может не знать ВСП на момент -- создания, дозаполнит позже через upd_form_cell(.., 'header.vsp_id', ..). -- -- FORM_3 не поддерживается — там строки живут в rf_project_report_line, -- API будет в add_form3_line.sql (отдельная итерация). -- -- Возврат: вся иерархия (ROOT/GROUP/ITEM/SUB_ITEM/SECTION/LEAF) + новая -- INPUT (отфильтрована по новому line_id). Новый id извлекается из -- (data->>'line_id')::INT первой INPUT-строки. -- ════════════════════════════════════════════════════════════════════════════ DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR); DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR, VARCHAR, DATE); CREATE FUNCTION v3.add_budget_line( p_form_id INT, p_expense_item_id INT DEFAULT NULL, p_sheet VARCHAR DEFAULT NULL, p_item_id VARCHAR DEFAULT NULL, p_section_code VARCHAR DEFAULT NULL, p_direction VARCHAR DEFAULT NULL, p_name VARCHAR DEFAULT NULL, p_internal_order VARCHAR DEFAULT NULL, p_vsp_id INT DEFAULT NULL, p_project_id INT DEFAULT NULL, p_justification VARCHAR DEFAULT NULL, -- сателлитные листы (AHR_RENT/UTILITY/SECURITY) используют дополнительно: p_contract_number VARCHAR DEFAULT NULL, p_contract_end_date DATE DEFAULT NULL ) RETURNS TABLE ( row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB ) 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; BEGIN -- 1. form_id → form_type 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; -- ═══ Сателлитные листы (AHR_RENT/UTILITY/SECURITY) ═══════════════════ -- Фиксированный expense_item_id на лист — UI не передаёт R-код. -- p_vsp_id обязателен. Опц.: p_contract_number, p_contract_end_date. -- Внутри: находим/создаём родительскую budget_line с фиксированным ei, -- затем INSERT в *_detail. Возврат — одна новая LEAF. 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 -- R061031001 "Аренда_ОН" WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги" WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН" 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; -- Находим/создаём родительскую budget_line под фиксированным ei 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; -- INSERT в сателлит 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; -- Возврат — одна новая INPUT-строка (отфильтрована по новому 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; -- 2. Резолв expense_item_id 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; -- 3. Авто-direction (для FORM_1/AHR|CAP — обязателен у юзера) 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); -- 4. Sanity на vsp/project 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; -- 5. INSERT 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; -- 5а. Аудит: ROW_CREATE PERFORM v3.log_event( 'ROW_CREATE', 'ROW_CREATE', 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 ); -- 6. Возврат: только иерархия по пути нового ei + новая INPUT 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$ LANGUAGE plpgsql VOLATILE;