2603 lines
122 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ════════════════════════════════════════════════════════════════════════════
-- v3.upd_form3_cell / v3.upd_form3_cells — точечная правка ячейки FORM_3.
-- Хранение FORM_3 идёт через rf_project_report → rf_project_report_line →
-- rf_project_report_quarter (PK = (rf_project_report_line_id, quarter)).
-- API симметричен upd_form_cell, но идентификатор «строки» — это
-- rf_project_report_line.id (то же `line_id`, что в JSONB read-функции).
--
-- Поддерживаемые ключи p_column (см. v_form3_report_jsonb.sql):
-- Факт: 'qN.adj_by_items', 'qN.adj_increase', 'qN.m1', 'qN.m2', 'qN.m3'
-- (+ 'q4.spod' — только IV квартал)
-- Базовый план: 'q1.base_plan' … 'q4.base_plan' (все 4 квартала)
-- Скоррект план: 'q2.corrected_plan' … 'q4.corrected_plan'
-- (без q1 — шаблон рисует скорр.план с II квартала)
--
-- Computed (RO): qN.total_corr, qN.quarter_actual, qN.economy (=«Остаток»),
-- qN.carryover, base_plan.year / totals.* (годовые итоги считаются на лету).
--
-- Атрибуты rf_project_report (dev_type, vsp_format, object_address, ...) —
-- НЕ через эту функцию (это header отчёта, не ячейка строки).
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3._apply_form3_cell(INT, TEXT, JSONB);
CREATE FUNCTION v3._apply_form3_cell(
p_line_id INT, -- rf_project_report_line.id
p_column TEXT,
p_value JSONB
)
RETURNS VOID
AS $function$
DECLARE
v_parts TEXT[];
v_scope TEXT;
v_field TEXT;
v_q SMALLINT;
v_target_col TEXT;
v_target_type TEXT;
v_sql TEXT;
BEGIN
v_parts := string_to_array(p_column, '.');
IF array_length(v_parts, 1) < 2 THEN
RAISE EXCEPTION 'bad_column_format: %, expected scope.field', p_column;
END IF;
v_scope := v_parts[1];
v_field := v_parts[2];
IF v_scope = 'totals' OR v_field IN ('total_corr','quarter_actual','economy','carryover') THEN
RAISE EXCEPTION 'computed_field: %', p_column;
END IF;
IF v_scope NOT IN ('q1','q2','q3','q4') THEN
RAISE EXCEPTION 'unknown_scope: % (FORM_3 supports only q1..q4)', v_scope;
END IF;
v_q := substring(v_scope FROM 2)::SMALLINT;
-- Маппинг JSONB-ключ → колонка rf_project_report_quarter
CASE v_field
WHEN 'adj_by_items' THEN v_target_col := 'adj_by_items'; v_target_type := 'NUMERIC';
WHEN 'adj_increase' THEN v_target_col := 'adj_increase'; v_target_type := 'NUMERIC';
WHEN 'm1' THEN v_target_col := 'actual_m1'; v_target_type := 'NUMERIC';
WHEN 'm2' THEN v_target_col := 'actual_m2'; v_target_type := 'NUMERIC';
WHEN 'm3' THEN v_target_col := 'actual_m3'; v_target_type := 'NUMERIC';
WHEN 'spod' THEN
IF v_q <> 4 THEN RAISE EXCEPTION 'spod is q4-only: %', p_column; END IF;
v_target_col := 'actual_spod'; v_target_type := 'NUMERIC';
WHEN 'base_plan' THEN v_target_col := 'base_plan'; v_target_type := 'NUMERIC';
WHEN 'corrected_plan' THEN
IF v_q = 1 THEN RAISE EXCEPTION 'corrected_plan is q2-q4 only: %', p_column; END IF;
v_target_col := 'corrected_plan'; v_target_type := 'NUMERIC';
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
v_sql := format(
'INSERT INTO v3.rf_project_report_quarter (rf_project_report_line_id, quarter, %1$I) '
|| 'VALUES ($1, $2, ($3 #>> ''{}'')::%2$s) '
|| 'ON CONFLICT (rf_project_report_line_id, quarter) DO UPDATE SET %1$I = EXCLUDED.%1$I',
v_target_col, v_target_type
);
EXECUTE v_sql USING p_line_id, v_q, p_value;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── Public batch ──────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cells(INT, JSONB);
CREATE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_line_id INT;
v_column TEXT;
v_value JSONB;
v_changed_lines INT[] := ARRAY[]::INT[];
BEGIN
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_line_id := (v_change->>'line_id')::INT;
v_column := v_change->>'column';
v_value := v_change->'value';
IF v_line_id IS NULL OR v_column IS NULL THEN
RAISE EXCEPTION 'change must have line_id and column: %', v_change;
END IF;
IF NOT EXISTS (
SELECT 1 FROM v3.rf_project_report_line
WHERE id = v_line_id AND rf_project_report_id = p_report_id
) THEN
RAISE EXCEPTION 'rf_project_report_line id=% не принадлежит report id=%',
v_line_id, p_report_id;
END IF;
PERFORM v3._apply_form3_cell(v_line_id, v_column, v_value);
IF NOT (v_line_id = ANY(v_changed_lines)) THEN
v_changed_lines := v_changed_lines || v_line_id;
END IF;
END LOOP;
-- Возврат: иерархия по пути изменённых rfprl + изменённые INPUT по line_id
DECLARE
v_eid_set INT[];
v_ei_path INT[];
BEGIN
SELECT array_agg(DISTINCT rfprl.expense_item_id)
INTO v_eid_set
FROM v3.rf_project_report_line rfprl
WHERE rfprl.id = ANY(v_changed_lines);
v_ei_path := v3._expense_item_path_set(v_eid_set);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(p_report_id, NULL) 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 = ANY(v_changed_lines))
ORDER BY v.sort_order;
END;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── Public single-cell wrapper ────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cell(INT, INT, TEXT, JSONB);
CREATE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
))
);
$function$
LANGUAGE sql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- FORM_3 write API: project header + line ADD/DEL + project ADD-master
-- ════════════════════════════════════════════════════════════════════════════
-- Дополняет существующий v3.upd_form3_cell (правка ячеек квартальных
-- значений в rf_project_report_quarter) — здесь функции для:
-- 1) v3.upd_project — правка атрибутов карточки проекта
-- 2) v3.add_form3_line — [DEPRECATED в UI] добавить позицию вручную.
-- 3) v3.del_form3_line — [DEPRECATED в UI] удалить позицию.
-- В FORM_3 сетка статей фиксирована (нет макроса добавления строк) —
-- строки засеваются автоматически, юзер пишет прямо в строку статьи.
-- add/del_form3_line оставлены для админ-правок/legacy.
-- 3b) v3.seed_form3_report_lines — засев полного набора листовых статей FORM_3.
-- 3c) v3.add_project_year — динамическое дорастание проекта годом
-- (2 отчёта + snapshot фаз + сетка статей).
-- 4) v3.add_project — мастер: project + первый год (add_project_year).
-- ════════════════════════════════════════════════════════════════════════════
-- ─── 1. upd_project ────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_project(INT, TEXT, JSONB);
CREATE FUNCTION v3.upd_project(
p_project_id INT,
p_column TEXT,
p_value JSONB
)
RETURNS JSONB -- весь header проекта после правки
AS $function$
DECLARE
v_target_col TEXT;
v_target_type TEXT;
v_sql TEXT;
v_out JSONB;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
-- Editable атрибуты (CHECK-ы из migrate_form3_v1.sql применятся при UPDATE).
-- Не editable: level (структура), parent_id (иерархия).
CASE p_column
WHEN 'name' THEN v_target_col := 'name'; v_target_type := 'TEXT';
WHEN 'project_type' THEN v_target_col := 'project_type'; v_target_type := 'TEXT';
WHEN 'vsp_format' THEN v_target_col := 'vsp_format'; v_target_type := 'TEXT';
WHEN 'placement_type' THEN v_target_col := 'placement_type'; v_target_type := 'TEXT';
WHEN 'object_address' THEN v_target_col := 'object_address'; v_target_type := 'TEXT';
WHEN 'system_code' THEN v_target_col := 'system_code'; v_target_type := 'TEXT';
WHEN 'staff_count' THEN v_target_col := 'staff_count'; v_target_type := 'INTEGER';
WHEN 'total_area' THEN v_target_col := 'total_area'; v_target_type := 'NUMERIC';
WHEN 'org_unit_id' THEN v_target_col := 'org_unit_id'; v_target_type := 'INTEGER';
WHEN 'level','parent_id' THEN
RAISE EXCEPTION 'structural_field: % (правится отдельно через ADD project / структурный API)', p_column;
WHEN 'id' THEN
RAISE EXCEPTION 'key_field: id (RO)';
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
-- FK санити (если применимо)
IF v_target_col = 'org_unit_id' AND p_value <> 'null'::jsonb THEN
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = (p_value #>> '{}')::INT) THEN
RAISE EXCEPTION 'org_unit id=% не существует', (p_value #>> '{}');
END IF;
END IF;
v_sql := format(
'UPDATE v3.project SET %1$I = ($2 #>> ''{}'')::%2$s WHERE id = $1',
v_target_col, v_target_type
);
EXECUTE v_sql USING p_project_id, p_value;
-- Возврат — весь header проекта
SELECT jsonb_build_object(
'id', p.id,
'name', p.name,
'level', p.level,
'parent_id', p.parent_id,
'org_unit_id', p.org_unit_id,
'project_type', p.project_type,
'vsp_format', p.vsp_format,
'placement_type', p.placement_type,
'object_address', p.object_address,
'system_code', p.system_code,
'staff_count', p.staff_count,
'total_area', p.total_area
)
INTO v_out
FROM v3.project p WHERE p.id = p_project_id;
RETURN v_out;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 2. add_form3_line ─────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.add_form3_line(INT, INT);
CREATE FUNCTION v3.add_form3_line(
p_report_id INT,
p_expense_item_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_new_id INT;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.expense_item WHERE id = p_expense_item_id) 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 = p_expense_item_id AND form_type_code = 'FORM_3') THEN
RAISE EXCEPTION 'expense_item id=% не привязан к form_type FORM_3', p_expense_item_id;
END IF;
INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id)
VALUES (p_report_id, p_expense_item_id)
RETURNING id INTO v_new_id;
-- Аудит: ROW_CREATE
PERFORM v3.log_event(
'ROW_CREATE', 'ROW_CREATE',
jsonb_build_object(
'sheet', 'FORM_3',
'line_id', v_new_id,
'report_id', p_report_id,
'expense_item_id', p_expense_item_id
)
);
-- Возврат: только иерархия по пути нового ei + новая INPUT
DECLARE v_ei_path INT[];
BEGIN
v_ei_path := v3._expense_item_path_set(ARRAY[p_expense_item_id]);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(p_report_id, NULL) 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;
-- ─── 3. del_form3_line ─────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.del_form3_line(INT);
CREATE FUNCTION v3.del_form3_line(
p_line_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_report_id INT;
v_eid INT;
v_ei_path INT[];
BEGIN
-- Извлекаем report_id + ei ДО DELETE (нужен для path)
SELECT rfprl.rf_project_report_id, rfprl.expense_item_id
INTO v_report_id, v_eid
FROM v3.rf_project_report_line rfprl
WHERE rfprl.id = p_line_id;
IF v_report_id IS NULL THEN
RAISE EXCEPTION 'rf_project_report_line id=% не существует', p_line_id;
END IF;
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
-- Каскад (FK без ON DELETE CASCADE)
DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = p_line_id;
DELETE FROM v3.rf_project_report_line WHERE id = p_line_id;
-- Аудит: ROW_DELETE
PERFORM v3.log_event(
'ROW_DELETE', 'ROW_DELETE',
jsonb_build_object(
'sheet', 'FORM_3',
'line_id', p_line_id,
'report_id', v_report_id,
'expense_item_id', v_eid
)
);
-- Возврат: только иерархия по пути удалённой (INPUT уже нет)
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(v_report_id, NULL) 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$
LANGUAGE plpgsql VOLATILE;
-- ─── 3b. seed_form3_report_lines (фиксированная сетка статей) ──────────────
-- В FORM_3 строки НЕ добавляются пользователем (в шаблоне нет макроса добавления
-- строк) — сетка статей фиксирована, юзер пишет прямо в строку статьи. Поэтому
-- при создании отчёта засеваем ПОЛНЫЙ набор листовых статей FORM_3 (листовая =
-- FORM_3-статья без FORM_3-детей). Идемпотентно (uq_v3_rf_project_report_line).
-- Возвращает число вставленных строк.
DROP FUNCTION IF EXISTS v3.seed_form3_report_lines(INT);
CREATE FUNCTION v3.seed_form3_report_lines(p_report_id INT)
RETURNS INT
AS $function$
DECLARE
v_cnt INT;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id)
SELECT p_report_id, e.id
FROM v3.expense_item e
JOIN v3.expense_item_form_type eft
ON eft.expense_item_id = e.id AND eft.form_type_code = 'FORM_3'
WHERE NOT EXISTS (
SELECT 1 FROM v3.expense_item c
JOIN v3.expense_item_form_type eftc
ON eftc.expense_item_id = c.id AND eftc.form_type_code = 'FORM_3'
WHERE c.parent_id = e.id
)
ON CONFLICT (rf_project_report_id, expense_item_id) DO NOTHING;
GET DIAGNOSTICS v_cnt = ROW_COUNT;
RETURN v_cnt;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 3c. add_project_year (динамическое дорастание проекта по годам) ───────
-- Проект может дополняться годами в процессе работы. Заводит пару отчётов
-- (LIMIT + CURRENT_EXPENSES) на (project, year), снапшотит RBAC-фазы и засевает
-- фиксированную сетку статей. Год несёт report_id — column_keys без префикса
-- года, RBAC работает пофазно на каждый год-отчёт (см. seed_phase_template).
DROP FUNCTION IF EXISTS v3.add_project_year(INT, INT, BOOLEAN);
CREATE FUNCTION v3.add_project_year(
p_project_id INT,
p_year INT,
p_emit_audit BOOLEAN DEFAULT true -- false когда зовётся из add_project (там PROJECT_CREATE)
)
RETURNS TABLE (
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_lim INT;
v_cur INT;
BEGIN
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
IF EXISTS (SELECT 1 FROM v3.rf_project_report
WHERE project_id = p_project_id AND year = p_year) THEN
RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id;
END IF;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, p_year, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
-- Snapshot RBAC-фаз (иначе default deny → отчёт read-only)
PERFORM v3.copy_template_to_report(v_lim);
PERFORM v3.copy_template_to_report(v_cur);
-- Фиксированная сетка статей на оба трека
PERFORM v3.seed_form3_report_lines(v_lim);
PERFORM v3.seed_form3_report_lines(v_cur);
IF p_emit_audit THEN
PERFORM v3.log_event(
'PROJECT_YEAR_ADD', 'PROJECT_YEAR_ADD',
jsonb_build_object(
'project_id', p_project_id,
'year', p_year,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
)
);
END IF;
RETURN QUERY SELECT v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 4. add_project (мастер: project + первый год через add_project_year) ──
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC, VARCHAR);
CREATE FUNCTION v3.add_project(
p_name VARCHAR,
p_year INT,
p_org_unit_id INT,
p_level VARCHAR DEFAULT 'project', -- 'program' | 'project'
p_parent_id INT DEFAULT NULL, -- обязателен если level='project' и есть программа
p_project_type VARCHAR DEFAULT NULL,
p_vsp_format VARCHAR DEFAULT NULL,
p_placement_type VARCHAR DEFAULT NULL,
p_object_address VARCHAR DEFAULT NULL,
p_staff_count INT DEFAULT NULL,
p_total_area NUMERIC DEFAULT NULL,
p_system_code VARCHAR DEFAULT NULL -- Системный код ВСП
)
RETURNS TABLE (
project_id INT,
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_pid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF p_org_unit_id IS NULL THEN
RAISE EXCEPTION 'p_org_unit_id обязателен';
END IF;
IF 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;
IF p_parent_id IS NOT NULL AND
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
END IF;
-- INSERT project (CHECK-ы name/project_type/vsp_format/placement_type
-- из migrate_form3_v1.sql применятся автоматически).
INSERT INTO v3.project (
name, level, parent_id, org_unit_id,
project_type, vsp_format, placement_type, object_address, staff_count, total_area,
system_code
) VALUES (
p_name, p_level, p_parent_id, p_org_unit_id,
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area,
p_system_code
) RETURNING id INTO v_pid;
-- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей.
-- p_emit_audit=false — событие года покрыто PROJECT_CREATE ниже. Дальнейшие
-- годы добавляются отдельно через v3.add_project_year.
SELECT y.limit_report_id, y.current_expenses_report_id
INTO v_lim, v_cur
FROM v3.add_project_year(v_pid, p_year, false) y;
-- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT_CREATE',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'year', p_year,
'level', p_level,
'parent_id', p_parent_id,
'org_unit_id', p_org_unit_id,
'project_type', p_project_type,
'vsp_format', p_vsp_format,
'placement_type', p_placement_type,
'object_address', p_object_address,
'system_code', p_system_code,
'staff_count', p_staff_count,
'total_area', p_total_area,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
))
);
RETURN QUERY SELECT v_pid, v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.v_form3_report_sections — отображение листа ЛП (LIMIT) или ТР (CURRENT)
-- ════════════════════════════════════════════════════════════════════════════
-- Возвращает иерархию (ROOT/GROUP/ITEM/SUB_ITEM) + INPUT строки в tree-order
-- для одного rf_project_report (один трек: report_type = LIMIT либо
-- CURRENT_EXPENSES). UI выбирает трек, передавая report_id соответствующей
-- записи rf_project_report.
--
-- Колоночная раскладка повторяет шаблон Формы 3:
-- header, q1, q2, q3, q4, year_totals.
--
-- Q1: adj_by_items, adj_increase, total_corr, m1, m2, m3, quarter_actual, economy
-- Q2/Q3: carryover, adj_by_items, adj_increase, total_corr, m1..m3, quarter_actual, economy
-- Q4: carryover, adj_by_items, adj_increase, total_corr, m1..m3, spod, quarter_actual, economy
-- year_totals: total_year_corr, total_year_actual, economy_year
--
-- p_sections — массив включаемых блоков (NULL = все). Допустимые имена:
-- 'q1', 'q2', 'q3', 'q4', 'year'
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3.v_form3_report_sections(INT, TEXT[]);
CREATE FUNCTION v3.v_form3_report_sections(
p_report_id INT,
p_sections TEXT[] DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
line_id INT,
col_section_code VARCHAR,
col_item_id VARCHAR,
col_num_group_id VARCHAR,
col_name VARCHAR,
-- Q1
col_q1_adj_by_items NUMERIC,
col_q1_adj_increase NUMERIC,
col_q1_total_corr NUMERIC,
col_q1_m1 NUMERIC,
col_q1_m2 NUMERIC,
col_q1_m3 NUMERIC,
col_q1_quarter_actual NUMERIC,
col_q1_economy NUMERIC,
-- Q2
col_q2_carryover NUMERIC,
col_q2_adj_by_items NUMERIC,
col_q2_adj_increase NUMERIC,
col_q2_total_corr NUMERIC,
col_q2_m1 NUMERIC,
col_q2_m2 NUMERIC,
col_q2_m3 NUMERIC,
col_q2_quarter_actual NUMERIC,
col_q2_economy NUMERIC,
-- Q3
col_q3_carryover NUMERIC,
col_q3_adj_by_items NUMERIC,
col_q3_adj_increase NUMERIC,
col_q3_total_corr NUMERIC,
col_q3_m1 NUMERIC,
col_q3_m2 NUMERIC,
col_q3_m3 NUMERIC,
col_q3_quarter_actual NUMERIC,
col_q3_economy NUMERIC,
-- Q4
col_q4_carryover NUMERIC,
col_q4_adj_by_items NUMERIC,
col_q4_adj_increase NUMERIC,
col_q4_total_corr NUMERIC,
col_q4_m1 NUMERIC,
col_q4_m2 NUMERIC,
col_q4_m3 NUMERIC,
col_q4_spod NUMERIC,
col_q4_quarter_actual NUMERIC,
col_q4_economy NUMERIC,
-- year totals
col_year_total_corr NUMERIC,
col_year_total_actual NUMERIC,
col_year_economy NUMERIC,
-- план-блоки (v2): Базовый план (q1q4 + Год) и Скоррект план (q2q4)
col_q1_base_plan NUMERIC,
col_q2_base_plan NUMERIC,
col_q3_base_plan NUMERIC,
col_q4_base_plan NUMERIC,
col_year_base_plan NUMERIC,
col_q2_corrected_plan NUMERIC,
col_q3_corrected_plan NUMERIC,
col_q4_corrected_plan NUMERIC,
_sort_path INT[]
)
AS $function$
DECLARE
s_q1 BOOLEAN := p_sections IS NULL OR 'q1' = ANY(p_sections);
s_q2 BOOLEAN := p_sections IS NULL OR 'q2' = ANY(p_sections);
s_q3 BOOLEAN := p_sections IS NULL OR 'q3' = ANY(p_sections);
s_q4 BOOLEAN := p_sections IS NULL OR 'q4' = ANY(p_sections);
s_year BOOLEAN := p_sections IS NULL OR 'year' = ANY(p_sections);
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
RETURN QUERY
WITH
-- Дерево expense_item, ограниченное FORM_3 (через junction)
tw AS (
SELECT t.* FROM v3.mv_expense_item_tree t
JOIN v3.expense_item_form_type eft
ON eft.expense_item_id = t.id AND eft.form_type_code = 'FORM_3'
),
-- INPUT строки: одна на v3.rf_project_report_line
page AS (
SELECT l.id AS lid, l.expense_item_id AS eid,
t.parent_item_id AS sc, -- section-код родителя в дереве (1.01.1.)
ei.item_id AS ic,
ei.num_group_id AS ng, ei.name AS ename,
t.path AS tree_path
FROM v3.rf_project_report_line l
JOIN v3.expense_item ei ON ei.id = l.expense_item_id
JOIN tw t ON t.id = l.expense_item_id
WHERE l.rf_project_report_id = p_report_id
),
-- Поквартальные данные
jq AS (
SELECT q.rf_project_report_line_id AS lid, q.quarter,
q.adj_by_items, q.adj_increase,
q.actual_m1, q.actual_m2, q.actual_m3, q.actual_spod,
q.base_plan, q.corrected_plan
FROM v3.rf_project_report_quarter q
JOIN v3.rf_project_report_line l ON l.id = q.rf_project_report_line_id
WHERE l.rf_project_report_id = p_report_id
),
-- INPUT base: соединяем page + 4 квартала
input_base AS (
SELECT
pg.lid, pg.eid, pg.sc, pg.ic, pg.ng, pg.ename, pg.tree_path,
q1.adj_by_items AS q1_abi, q1.adj_increase AS q1_ai,
q1.actual_m1 AS q1_m1, q1.actual_m2 AS q1_m2, q1.actual_m3 AS q1_m3,
q2.adj_by_items AS q2_abi, q2.adj_increase AS q2_ai,
q2.actual_m1 AS q2_m1, q2.actual_m2 AS q2_m2, q2.actual_m3 AS q2_m3,
q3.adj_by_items AS q3_abi, q3.adj_increase AS q3_ai,
q3.actual_m1 AS q3_m1, q3.actual_m2 AS q3_m2, q3.actual_m3 AS q3_m3,
q4.adj_by_items AS q4_abi, q4.adj_increase AS q4_ai,
q4.actual_m1 AS q4_m1, q4.actual_m2 AS q4_m2, q4.actual_m3 AS q4_m3,
q4.actual_spod AS q4_spod,
q1.base_plan AS q1_bp, q2.base_plan AS q2_bp,
q3.base_plan AS q3_bp, q4.base_plan AS q4_bp,
q2.corrected_plan AS q2_cp, q3.corrected_plan AS q3_cp, q4.corrected_plan AS q4_cp
FROM page pg
LEFT JOIN jq q1 ON q1.lid = pg.lid AND q1.quarter = 1
LEFT JOIN jq q2 ON q2.lid = pg.lid AND q2.quarter = 2
LEFT JOIN jq q3 ON q3.lid = pg.lid AND q3.quarter = 3
LEFT JOIN jq q4 ON q4.lid = pg.lid AND q4.quarter = 4
),
-- Computed totals per row
enriched AS (
SELECT b.*,
COALESCE(b.q1_abi,0) + COALESCE(b.q1_ai,0) AS q1_tc,
COALESCE(b.q1_m1,0) + COALESCE(b.q1_m2,0) + COALESCE(b.q1_m3,0) AS q1_qa,
COALESCE(b.q2_abi,0) + COALESCE(b.q2_ai,0) AS q2_tc,
COALESCE(b.q2_m1,0) + COALESCE(b.q2_m2,0) + COALESCE(b.q2_m3,0) AS q2_qa,
COALESCE(b.q3_abi,0) + COALESCE(b.q3_ai,0) AS q3_tc,
COALESCE(b.q3_m1,0) + COALESCE(b.q3_m2,0) + COALESCE(b.q3_m3,0) AS q3_qa,
COALESCE(b.q4_abi,0) + COALESCE(b.q4_ai,0) AS q4_tc,
COALESCE(b.q4_m1,0) + COALESCE(b.q4_m2,0) + COALESCE(b.q4_m3,0) + COALESCE(b.q4_spod,0) AS q4_qa
FROM input_base b
),
-- Per-line aggregates for tree rollup
agg_q AS (
SELECT l.expense_item_id AS eid, q.quarter,
SUM(q.adj_by_items) AS abi, SUM(q.adj_increase) AS ai,
SUM(q.actual_m1) AS m1, SUM(q.actual_m2) AS m2, SUM(q.actual_m3) AS m3,
SUM(q.actual_spod) AS spod,
SUM(q.base_plan) AS bp, SUM(q.corrected_plan) AS cp
FROM v3.rf_project_report_line l
JOIN v3.rf_project_report_quarter q ON q.rf_project_report_line_id = l.id
WHERE l.rf_project_report_id = p_report_id
GROUP BY l.expense_item_id, q.quarter
),
tw_aq1 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 1
GROUP BY tw.id
),
tw_aq2 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 2
GROUP BY tw.id
),
tw_aq3 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 3
GROUP BY tw.id
),
tw_aq4 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.spod) AS spod,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 4
GROUP BY tw.id
)
-- ═══ Часть A: INPUT строки ════════════════════════════════════════════════
SELECT * FROM (
SELECT
'INPUT'::VARCHAR AS row_type, 3 AS depth, e.lid::INT AS line_id,
e.sc, e.ic, e.ng, e.ename,
-- Q1
CASE WHEN s_q1 THEN e.q1_abi END,
CASE WHEN s_q1 THEN e.q1_ai END,
CASE WHEN s_q1 THEN e.q1_tc END,
CASE WHEN s_q1 THEN e.q1_m1 END,
CASE WHEN s_q1 THEN e.q1_m2 END,
CASE WHEN s_q1 THEN e.q1_m3 END,
CASE WHEN s_q1 THEN e.q1_qa END,
CASE WHEN s_q1 THEN ROUND(e.q1_tc - e.q1_qa, 1) END,
-- Q2 (carryover пока NULL — нет столбца в _quarter; задел на будущее)
CASE WHEN s_q2 THEN NULL::NUMERIC END,
CASE WHEN s_q2 THEN e.q2_abi END,
CASE WHEN s_q2 THEN e.q2_ai END,
CASE WHEN s_q2 THEN e.q2_tc END,
CASE WHEN s_q2 THEN e.q2_m1 END,
CASE WHEN s_q2 THEN e.q2_m2 END,
CASE WHEN s_q2 THEN e.q2_m3 END,
CASE WHEN s_q2 THEN e.q2_qa END,
CASE WHEN s_q2 THEN ROUND(e.q2_tc - e.q2_qa, 1) END,
-- Q3
CASE WHEN s_q3 THEN NULL::NUMERIC END,
CASE WHEN s_q3 THEN e.q3_abi END,
CASE WHEN s_q3 THEN e.q3_ai END,
CASE WHEN s_q3 THEN e.q3_tc END,
CASE WHEN s_q3 THEN e.q3_m1 END,
CASE WHEN s_q3 THEN e.q3_m2 END,
CASE WHEN s_q3 THEN e.q3_m3 END,
CASE WHEN s_q3 THEN e.q3_qa END,
CASE WHEN s_q3 THEN ROUND(e.q3_tc - e.q3_qa, 1) END,
-- Q4
CASE WHEN s_q4 THEN NULL::NUMERIC END,
CASE WHEN s_q4 THEN e.q4_abi END,
CASE WHEN s_q4 THEN e.q4_ai END,
CASE WHEN s_q4 THEN e.q4_tc END,
CASE WHEN s_q4 THEN e.q4_m1 END,
CASE WHEN s_q4 THEN e.q4_m2 END,
CASE WHEN s_q4 THEN e.q4_m3 END,
CASE WHEN s_q4 THEN e.q4_spod END,
CASE WHEN s_q4 THEN e.q4_qa END,
CASE WHEN s_q4 THEN ROUND(e.q4_tc - e.q4_qa, 1) END,
-- year
CASE WHEN s_year THEN e.q1_tc + e.q2_tc + e.q3_tc + e.q4_tc END,
CASE WHEN s_year THEN e.q1_qa + e.q2_qa + e.q3_qa + e.q4_qa END,
CASE WHEN s_year THEN ROUND((e.q1_tc + e.q2_tc + e.q3_tc + e.q4_tc) -
(e.q1_qa + e.q2_qa + e.q3_qa + e.q4_qa), 1) END,
-- план-блоки
CASE WHEN s_q1 THEN e.q1_bp END,
CASE WHEN s_q2 THEN e.q2_bp END,
CASE WHEN s_q3 THEN e.q3_bp END,
CASE WHEN s_q4 THEN e.q4_bp END,
CASE WHEN s_year THEN COALESCE(e.q1_bp,0)+COALESCE(e.q2_bp,0)+
COALESCE(e.q3_bp,0)+COALESCE(e.q4_bp,0) END,
CASE WHEN s_q2 THEN e.q2_cp END,
CASE WHEN s_q3 THEN e.q3_cp END,
CASE WHEN s_q4 THEN e.q4_cp END,
e.tree_path || ARRAY[e.lid::INT] AS _sort_path
FROM enriched e
UNION ALL
-- ═══ Часть B: Иерархия (ROOT / GROUP / ITEM / SUB_ITEM) ═════════════════
SELECT
CASE t.depth WHEN 0 THEN 'ROOT' WHEN 1 THEN 'GROUP'
WHEN 2 THEN 'ITEM' WHEN 3 THEN 'SUB_ITEM' END::VARCHAR,
t.depth, NULL::INT,
CASE WHEN t.depth < 3 THEN t.item_id ELSE t.parent_item_id END,
CASE WHEN t.depth = 3 THEN t.item_id ELSE NULL END,
t.num_group_id, t.name,
-- Q1
CASE WHEN s_q1 THEN a1.abi END,
CASE WHEN s_q1 THEN a1.ai END,
CASE WHEN s_q1 THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0) END,
CASE WHEN s_q1 THEN a1.m1 END,
CASE WHEN s_q1 THEN a1.m2 END,
CASE WHEN s_q1 THEN a1.m3 END,
CASE WHEN s_q1 THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0) END,
CASE WHEN s_q1 THEN ROUND((COALESCE(a1.abi,0)+COALESCE(a1.ai,0)) -
(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)), 1) END,
-- Q2
CASE WHEN s_q2 THEN NULL::NUMERIC END,
CASE WHEN s_q2 THEN a2.abi END,
CASE WHEN s_q2 THEN a2.ai END,
CASE WHEN s_q2 THEN COALESCE(a2.abi,0)+COALESCE(a2.ai,0) END,
CASE WHEN s_q2 THEN a2.m1 END,
CASE WHEN s_q2 THEN a2.m2 END,
CASE WHEN s_q2 THEN a2.m3 END,
CASE WHEN s_q2 THEN COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0) END,
CASE WHEN s_q2 THEN ROUND((COALESCE(a2.abi,0)+COALESCE(a2.ai,0)) -
(COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)), 1) END,
-- Q3
CASE WHEN s_q3 THEN NULL::NUMERIC END,
CASE WHEN s_q3 THEN a3.abi END,
CASE WHEN s_q3 THEN a3.ai END,
CASE WHEN s_q3 THEN COALESCE(a3.abi,0)+COALESCE(a3.ai,0) END,
CASE WHEN s_q3 THEN a3.m1 END,
CASE WHEN s_q3 THEN a3.m2 END,
CASE WHEN s_q3 THEN a3.m3 END,
CASE WHEN s_q3 THEN COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0) END,
CASE WHEN s_q3 THEN ROUND((COALESCE(a3.abi,0)+COALESCE(a3.ai,0)) -
(COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)), 1) END,
-- Q4
CASE WHEN s_q4 THEN NULL::NUMERIC END,
CASE WHEN s_q4 THEN a4.abi END,
CASE WHEN s_q4 THEN a4.ai END,
CASE WHEN s_q4 THEN COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_q4 THEN a4.m1 END,
CASE WHEN s_q4 THEN a4.m2 END,
CASE WHEN s_q4 THEN a4.m3 END,
CASE WHEN s_q4 THEN a4.spod END,
CASE WHEN s_q4 THEN COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_q4 THEN ROUND((COALESCE(a4.abi,0)+COALESCE(a4.ai,0)) -
(COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)), 1) END,
-- year
CASE WHEN s_year THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+
COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+
COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_year THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_year THEN
ROUND(
(COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0))
-
(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)),
1)
END,
-- план-блоки (rollup)
CASE WHEN s_q1 THEN a1.bp END,
CASE WHEN s_q2 THEN a2.bp END,
CASE WHEN s_q3 THEN a3.bp END,
CASE WHEN s_q4 THEN a4.bp END,
CASE WHEN s_year THEN COALESCE(a1.bp,0)+COALESCE(a2.bp,0)+
COALESCE(a3.bp,0)+COALESCE(a4.bp,0) END,
CASE WHEN s_q2 THEN a2.cp END,
CASE WHEN s_q3 THEN a3.cp END,
CASE WHEN s_q4 THEN a4.cp END,
t.path
FROM tw t
LEFT JOIN tw_aq1 a1 ON a1.id = t.id
LEFT JOIN tw_aq2 a2 ON a2.id = t.id
LEFT JOIN tw_aq3 a3 ON a3.id = t.id
LEFT JOIN tw_aq4 a4 ON a4.id = t.id
) sub
ORDER BY sub._sort_path;
END;
$function$
LANGUAGE plpgsql STABLE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.upd_form3_cell / v3.upd_form3_cells — обёртки FORM_3 с проверкой роли+окна
-- ════════════════════════════════════════════════════════════════════════════
-- Параллель upd_form_cell_authz.sql, но для ветки rf_project_report. Каждая
-- ячейка прогоняется через v3.can_edit3(report_id, column, user_id). Если хоть
-- одна запрещена — RAISE EXCEPTION в формате, совместимом с конвенцией v3:
-- • role_not_allowed: <column>
-- • org_not_assigned: <column>
-- • window_closed: <column> (opens <ISO8601>) — окно ещё не наступило
-- • window_closed: <column> (closed <ISO8601>) — окно уже закрыто
-- • window_closed: <column> (no phase) — фаз вообще нет
--
-- HTTP-слой парсит префикс до `:` и мапит на 403 (см. SPEC.md §5).
--
-- ⚠ Применять СТРОГО после upd_form3_cell.sql (low-level 2-арг/4-арг). Здесь
-- добавляется p_user_id → это ДРУГИЕ сигнатуры (3-арг/5-арг), которые
-- сосуществуют с low-level и делегируют в них после проверки. HTTP вызывает
-- версии с user_id. «Листа» тут нет — report_type определяется отчётом внутри
-- can_edit3.
-- ════════════════════════════════════════════════════════════════════════════
-- ─── upd_form3_cells (массовый, с авторизацией) ────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cells(INT, JSONB, INT);
CREATE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_column TEXT;
v_decision JSONB;
v_msg TEXT;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'role_not_allowed: <missing user_id>';
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';
IF v_column IS NULL THEN
RAISE EXCEPTION 'change must have column: %', v_change;
END IF;
v_decision := v3.can_edit3(p_report_id, 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;
-- Все ячейки разрешены — делегируем в low-level (2-арг). ROW_UPDATE не
-- логируем (см. logging_events.md): cell-edit'ы слишком частые.
RETURN QUERY
SELECT * FROM v3.upd_form3_cells(p_report_id, p_changes);
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── upd_form3_cell (одна ячейка, тонкий wrapper) ──────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cell(INT, INT, TEXT, JSONB, INT);
CREATE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
)),
p_user_id
);
$function$
LANGUAGE sql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.migrate_form3_v2 — DDL под переработку FORM_3 (листы ЛП/ТР, v2)
-- ════════════════════════════════════════════════════════════════════════════
-- Источник: «Примерорма_3_new(1).xlsm» (листы шаблон_ЛП / шаблон_ТР) + PDF-дифф
-- аналитика. Финансовая часть переразбита с 4 на 10 блоков и переведена на
-- многолетнюю раскладку (год1/год2/год3 рядом, кварталы повторяются на год).
--
-- Новые блоки внутри одного года:
-- • Базовый план по Лимиту Правления — ручной ввод q1q4, Год = Σ кв.
-- • Скоррект план по Лимиту Правления — ручной ввод q2q4 (без q1, без Года)
-- • Фактические затраты — уже покрыты rf_project_report_quarter
-- (adj_by_items / adj_increase / actual_m1..m3 / actual_spod), «Остаток» и
-- квартальные/годовые итоги считаются на лету в read-функции.
--
-- Многолетность уже поддержана моделью: один project → много rf_project_report
-- по (year × report_type). Здесь добавляется ТОЛЬКО хранение план-блоков и
-- «Системный код ВСП» в шапке проекта.
-- ════════════════════════════════════════════════════════════════════════════
-- ───────────── 1. rf_project_report_quarter: план-блоки ─────────────────────
-- base_plan — «Базовый план по Лимиту Правления», поквартально (q1q4).
-- corrected_plan — «Скоррект план по Лимиту Правления», поквартально (q2q4).
-- Для q1 значения не существует (шаблон рисует скорр.план с
-- II квартала) — гарантируется CHECK ниже + write-слой/RBAC.
ALTER TABLE v3.rf_project_report_quarter
ADD COLUMN IF NOT EXISTS base_plan NUMERIC,
ADD COLUMN IF NOT EXISTS corrected_plan NUMERIC;
-- Скоррект план не заводится в I квартале (нет колонки в шаблоне).
ALTER TABLE v3.rf_project_report_quarter
DROP CONSTRAINT IF EXISTS chk_v3_rf_quarter_corr_plan_q1;
ALTER TABLE v3.rf_project_report_quarter
ADD CONSTRAINT chk_v3_rf_quarter_corr_plan_q1
CHECK (quarter <> 1 OR corrected_plan IS NULL);
-- ───────────── 2. v3.project: «Системный код ВСП» ───────────────────────────
-- Новое поле шапки «Информация о проекте ВСП» (в примере — 1808). Выбор из
-- справочника ИНФО / выпадающего списка; хранится как атрибут проекта (вводится
-- один раз, не дублируется между треками/годами — как и остальные атрибуты ВСП).
ALTER TABLE v3.project
ADD COLUMN IF NOT EXISTS system_code VARCHAR;
-- ───────────── 3. Обновление энум-списков шапки ────────────────────────────
-- В новом шаблоне списки изменились относительно старого VBA-набора.
-- project_type: новый перечень из 8 значений (шаблон_ЛП F2:F9).
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_project_type_check;
ALTER TABLE v3.project
ADD CONSTRAINT project_project_type_check
CHECK (project_type IS NULL OR project_type IN (
'Открытие ВСП','Реновация ВСП','Закрытие ВСП',
'Реновация РФ','Закрытие РФ',
'Открытие УРМ','Реновация УРМ','Закрытие УРМ'
));
-- vsp_format («Новый формат ВСП») — поле автозаполняется из справочника INFO
-- («Утвержденный формат»), не вводится вручную и его словарь ведётся как
-- reference-data. Жёсткий CHECK снимаем, чтобы новые форматы не ломали вставку.
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_vsp_format_check;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.upd_form3_cell / v3.upd_form3_cells — точечная правка ячейки FORM_3.
-- Хранение FORM_3 идёт через rf_project_report → rf_project_report_line →
-- rf_project_report_quarter (PK = (rf_project_report_line_id, quarter)).
-- API симметричен upd_form_cell, но идентификатор «строки» — это
-- rf_project_report_line.id (то же `line_id`, что в JSONB read-функции).
--
-- Поддерживаемые ключи p_column (см. v_form3_report_jsonb.sql):
-- Факт: 'qN.adj_by_items', 'qN.adj_increase', 'qN.m1', 'qN.m2', 'qN.m3'
-- (+ 'q4.spod' — только IV квартал)
-- Базовый план: 'q1.base_plan' … 'q4.base_plan' (все 4 квартала)
-- Скоррект план: 'q2.corrected_plan' … 'q4.corrected_plan'
-- (без q1 — шаблон рисует скорр.план с II квартала)
--
-- Computed (RO): qN.total_corr, qN.quarter_actual, qN.economy (=«Остаток»),
-- qN.carryover, base_plan.year / totals.* (годовые итоги считаются на лету).
--
-- Атрибуты rf_project_report (dev_type, vsp_format, object_address, ...) —
-- НЕ через эту функцию (это header отчёта, не ячейка строки).
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3._apply_form3_cell(INT, TEXT, JSONB);
CREATE FUNCTION v3._apply_form3_cell(
p_line_id INT, -- rf_project_report_line.id
p_column TEXT,
p_value JSONB
)
RETURNS VOID
AS $function$
DECLARE
v_parts TEXT[];
v_scope TEXT;
v_field TEXT;
v_q SMALLINT;
v_target_col TEXT;
v_target_type TEXT;
v_sql TEXT;
BEGIN
v_parts := string_to_array(p_column, '.');
IF array_length(v_parts, 1) < 2 THEN
RAISE EXCEPTION 'bad_column_format: %, expected scope.field', p_column;
END IF;
v_scope := v_parts[1];
v_field := v_parts[2];
IF v_scope = 'totals' OR v_field IN ('total_corr','quarter_actual','economy','carryover') THEN
RAISE EXCEPTION 'computed_field: %', p_column;
END IF;
IF v_scope NOT IN ('q1','q2','q3','q4') THEN
RAISE EXCEPTION 'unknown_scope: % (FORM_3 supports only q1..q4)', v_scope;
END IF;
v_q := substring(v_scope FROM 2)::SMALLINT;
-- Маппинг JSONB-ключ → колонка rf_project_report_quarter
CASE v_field
WHEN 'adj_by_items' THEN v_target_col := 'adj_by_items'; v_target_type := 'NUMERIC';
WHEN 'adj_increase' THEN v_target_col := 'adj_increase'; v_target_type := 'NUMERIC';
WHEN 'm1' THEN v_target_col := 'actual_m1'; v_target_type := 'NUMERIC';
WHEN 'm2' THEN v_target_col := 'actual_m2'; v_target_type := 'NUMERIC';
WHEN 'm3' THEN v_target_col := 'actual_m3'; v_target_type := 'NUMERIC';
WHEN 'spod' THEN
IF v_q <> 4 THEN RAISE EXCEPTION 'spod is q4-only: %', p_column; END IF;
v_target_col := 'actual_spod'; v_target_type := 'NUMERIC';
WHEN 'base_plan' THEN v_target_col := 'base_plan'; v_target_type := 'NUMERIC';
WHEN 'corrected_plan' THEN
IF v_q = 1 THEN RAISE EXCEPTION 'corrected_plan is q2-q4 only: %', p_column; END IF;
v_target_col := 'corrected_plan'; v_target_type := 'NUMERIC';
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
v_sql := format(
'INSERT INTO v3.rf_project_report_quarter (rf_project_report_line_id, quarter, %1$I) '
|| 'VALUES ($1, $2, ($3 #>> ''{}'')::%2$s) '
|| 'ON CONFLICT (rf_project_report_line_id, quarter) DO UPDATE SET %1$I = EXCLUDED.%1$I',
v_target_col, v_target_type
);
EXECUTE v_sql USING p_line_id, v_q, p_value;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── Public batch ──────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cells(INT, JSONB);
CREATE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_line_id INT;
v_column TEXT;
v_value JSONB;
v_changed_lines INT[] := ARRAY[]::INT[];
BEGIN
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_line_id := (v_change->>'line_id')::INT;
v_column := v_change->>'column';
v_value := v_change->'value';
IF v_line_id IS NULL OR v_column IS NULL THEN
RAISE EXCEPTION 'change must have line_id and column: %', v_change;
END IF;
IF NOT EXISTS (
SELECT 1 FROM v3.rf_project_report_line
WHERE id = v_line_id AND rf_project_report_id = p_report_id
) THEN
RAISE EXCEPTION 'rf_project_report_line id=% не принадлежит report id=%',
v_line_id, p_report_id;
END IF;
PERFORM v3._apply_form3_cell(v_line_id, v_column, v_value);
IF NOT (v_line_id = ANY(v_changed_lines)) THEN
v_changed_lines := v_changed_lines || v_line_id;
END IF;
END LOOP;
-- Возврат: иерархия по пути изменённых rfprl + изменённые INPUT по line_id
DECLARE
v_eid_set INT[];
v_ei_path INT[];
BEGIN
SELECT array_agg(DISTINCT rfprl.expense_item_id)
INTO v_eid_set
FROM v3.rf_project_report_line rfprl
WHERE rfprl.id = ANY(v_changed_lines);
v_ei_path := v3._expense_item_path_set(v_eid_set);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(p_report_id, NULL) 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 = ANY(v_changed_lines))
ORDER BY v.sort_order;
END;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── Public single-cell wrapper ────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cell(INT, INT, TEXT, JSONB);
CREATE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
))
);
$function$
LANGUAGE sql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- FORM_3 write API: project header + line ADD/DEL + project ADD-master
-- ════════════════════════════════════════════════════════════════════════════
-- Дополняет существующий v3.upd_form3_cell (правка ячеек квартальных
-- значений в rf_project_report_quarter) — здесь функции для:
-- 1) v3.upd_project — правка атрибутов карточки проекта
-- 2) v3.add_form3_line — [DEPRECATED в UI] добавить позицию вручную.
-- 3) v3.del_form3_line — [DEPRECATED в UI] удалить позицию.
-- В FORM_3 сетка статей фиксирована (нет макроса добавления строк) —
-- строки засеваются автоматически, юзер пишет прямо в строку статьи.
-- add/del_form3_line оставлены для админ-правок/legacy.
-- 3b) v3.seed_form3_report_lines — засев полного набора листовых статей FORM_3.
-- 3c) v3.add_project_year — динамическое дорастание проекта годом
-- (2 отчёта + snapshot фаз + сетка статей).
-- 4) v3.add_project — мастер: project + первый год (add_project_year).
-- ════════════════════════════════════════════════════════════════════════════
-- ─── 1. upd_project ────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_project(INT, TEXT, JSONB);
CREATE FUNCTION v3.upd_project(
p_project_id INT,
p_column TEXT,
p_value JSONB
)
RETURNS JSONB -- весь header проекта после правки
AS $function$
DECLARE
v_target_col TEXT;
v_target_type TEXT;
v_sql TEXT;
v_out JSONB;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
-- Editable атрибуты (CHECK-ы из migrate_form3_v1.sql применятся при UPDATE).
-- Не editable: level (структура), parent_id (иерархия).
CASE p_column
WHEN 'name' THEN v_target_col := 'name'; v_target_type := 'TEXT';
WHEN 'project_type' THEN v_target_col := 'project_type'; v_target_type := 'TEXT';
WHEN 'vsp_format' THEN v_target_col := 'vsp_format'; v_target_type := 'TEXT';
WHEN 'placement_type' THEN v_target_col := 'placement_type'; v_target_type := 'TEXT';
WHEN 'object_address' THEN v_target_col := 'object_address'; v_target_type := 'TEXT';
WHEN 'system_code' THEN v_target_col := 'system_code'; v_target_type := 'TEXT';
WHEN 'staff_count' THEN v_target_col := 'staff_count'; v_target_type := 'INTEGER';
WHEN 'total_area' THEN v_target_col := 'total_area'; v_target_type := 'NUMERIC';
WHEN 'org_unit_id' THEN v_target_col := 'org_unit_id'; v_target_type := 'INTEGER';
WHEN 'level','parent_id' THEN
RAISE EXCEPTION 'structural_field: % (правится отдельно через ADD project / структурный API)', p_column;
WHEN 'id' THEN
RAISE EXCEPTION 'key_field: id (RO)';
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
-- FK санити (если применимо)
IF v_target_col = 'org_unit_id' AND p_value <> 'null'::jsonb THEN
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = (p_value #>> '{}')::INT) THEN
RAISE EXCEPTION 'org_unit id=% не существует', (p_value #>> '{}');
END IF;
END IF;
v_sql := format(
'UPDATE v3.project SET %1$I = ($2 #>> ''{}'')::%2$s WHERE id = $1',
v_target_col, v_target_type
);
EXECUTE v_sql USING p_project_id, p_value;
-- Возврат — весь header проекта
SELECT jsonb_build_object(
'id', p.id,
'name', p.name,
'level', p.level,
'parent_id', p.parent_id,
'org_unit_id', p.org_unit_id,
'project_type', p.project_type,
'vsp_format', p.vsp_format,
'placement_type', p.placement_type,
'object_address', p.object_address,
'system_code', p.system_code,
'staff_count', p.staff_count,
'total_area', p.total_area
)
INTO v_out
FROM v3.project p WHERE p.id = p_project_id;
RETURN v_out;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 2. add_form3_line ─────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.add_form3_line(INT, INT);
CREATE FUNCTION v3.add_form3_line(
p_report_id INT,
p_expense_item_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_new_id INT;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.expense_item WHERE id = p_expense_item_id) 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 = p_expense_item_id AND form_type_code = 'FORM_3') THEN
RAISE EXCEPTION 'expense_item id=% не привязан к form_type FORM_3', p_expense_item_id;
END IF;
INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id)
VALUES (p_report_id, p_expense_item_id)
RETURNING id INTO v_new_id;
-- Аудит: ROW_CREATE
PERFORM v3.log_event(
'ROW_CREATE', 'ROW_CREATE',
jsonb_build_object(
'sheet', 'FORM_3',
'line_id', v_new_id,
'report_id', p_report_id,
'expense_item_id', p_expense_item_id
)
);
-- Возврат: только иерархия по пути нового ei + новая INPUT
DECLARE v_ei_path INT[];
BEGIN
v_ei_path := v3._expense_item_path_set(ARRAY[p_expense_item_id]);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(p_report_id, NULL) 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;
-- ─── 3. del_form3_line ─────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.del_form3_line(INT);
CREATE FUNCTION v3.del_form3_line(
p_line_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_report_id INT;
v_eid INT;
v_ei_path INT[];
BEGIN
-- Извлекаем report_id + ei ДО DELETE (нужен для path)
SELECT rfprl.rf_project_report_id, rfprl.expense_item_id
INTO v_report_id, v_eid
FROM v3.rf_project_report_line rfprl
WHERE rfprl.id = p_line_id;
IF v_report_id IS NULL THEN
RAISE EXCEPTION 'rf_project_report_line id=% не существует', p_line_id;
END IF;
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
-- Каскад (FK без ON DELETE CASCADE)
DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = p_line_id;
DELETE FROM v3.rf_project_report_line WHERE id = p_line_id;
-- Аудит: ROW_DELETE
PERFORM v3.log_event(
'ROW_DELETE', 'ROW_DELETE',
jsonb_build_object(
'sheet', 'FORM_3',
'line_id', p_line_id,
'report_id', v_report_id,
'expense_item_id', v_eid
)
);
-- Возврат: только иерархия по пути удалённой (INPUT уже нет)
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form3_report_jsonb(v_report_id, NULL) 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$
LANGUAGE plpgsql VOLATILE;
-- ─── 3b. seed_form3_report_lines (фиксированная сетка статей) ──────────────
-- В FORM_3 строки НЕ добавляются пользователем (в шаблоне нет макроса добавления
-- строк) — сетка статей фиксирована, юзер пишет прямо в строку статьи. Поэтому
-- при создании отчёта засеваем ПОЛНЫЙ набор листовых статей FORM_3 (листовая =
-- FORM_3-статья без FORM_3-детей). Идемпотентно (uq_v3_rf_project_report_line).
-- Возвращает число вставленных строк.
DROP FUNCTION IF EXISTS v3.seed_form3_report_lines(INT);
CREATE FUNCTION v3.seed_form3_report_lines(p_report_id INT)
RETURNS INT
AS $function$
DECLARE
v_cnt INT;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id)
SELECT p_report_id, e.id
FROM v3.expense_item e
JOIN v3.expense_item_form_type eft
ON eft.expense_item_id = e.id AND eft.form_type_code = 'FORM_3'
WHERE NOT EXISTS (
SELECT 1 FROM v3.expense_item c
JOIN v3.expense_item_form_type eftc
ON eftc.expense_item_id = c.id AND eftc.form_type_code = 'FORM_3'
WHERE c.parent_id = e.id
)
ON CONFLICT (rf_project_report_id, expense_item_id) DO NOTHING;
GET DIAGNOSTICS v_cnt = ROW_COUNT;
RETURN v_cnt;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 3c. add_project_year (динамическое дорастание проекта по годам) ───────
-- Проект может дополняться годами в процессе работы. Заводит пару отчётов
-- (LIMIT + CURRENT_EXPENSES) на (project, year), снапшотит RBAC-фазы и засевает
-- фиксированную сетку статей. Год несёт report_id — column_keys без префикса
-- года, RBAC работает пофазно на каждый год-отчёт (см. seed_phase_template).
DROP FUNCTION IF EXISTS v3.add_project_year(INT, INT, BOOLEAN);
CREATE FUNCTION v3.add_project_year(
p_project_id INT,
p_year INT,
p_emit_audit BOOLEAN DEFAULT true -- false когда зовётся из add_project (там PROJECT_CREATE)
)
RETURNS TABLE (
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_lim INT;
v_cur INT;
BEGIN
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
IF EXISTS (SELECT 1 FROM v3.rf_project_report
WHERE project_id = p_project_id AND year = p_year) THEN
RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id;
END IF;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, p_year, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
-- Snapshot RBAC-фаз (иначе default deny → отчёт read-only)
PERFORM v3.copy_template_to_report(v_lim);
PERFORM v3.copy_template_to_report(v_cur);
-- Фиксированная сетка статей на оба трека
PERFORM v3.seed_form3_report_lines(v_lim);
PERFORM v3.seed_form3_report_lines(v_cur);
IF p_emit_audit THEN
PERFORM v3.log_event(
'PROJECT_YEAR_ADD', 'PROJECT_YEAR_ADD',
jsonb_build_object(
'project_id', p_project_id,
'year', p_year,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
)
);
END IF;
RETURN QUERY SELECT v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── 4. add_project (мастер: project + первый год через add_project_year) ──
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC);
DROP FUNCTION IF EXISTS v3.add_project(VARCHAR, INT, INT, VARCHAR, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, NUMERIC, VARCHAR);
CREATE FUNCTION v3.add_project(
p_name VARCHAR,
p_year INT,
p_org_unit_id INT,
p_level VARCHAR DEFAULT 'project', -- 'program' | 'project'
p_parent_id INT DEFAULT NULL, -- обязателен если level='project' и есть программа
p_project_type VARCHAR DEFAULT NULL,
p_vsp_format VARCHAR DEFAULT NULL,
p_placement_type VARCHAR DEFAULT NULL,
p_object_address VARCHAR DEFAULT NULL,
p_staff_count INT DEFAULT NULL,
p_total_area NUMERIC DEFAULT NULL,
p_system_code VARCHAR DEFAULT NULL -- Системный код ВСП
)
RETURNS TABLE (
project_id INT,
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_pid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF p_org_unit_id IS NULL THEN
RAISE EXCEPTION 'p_org_unit_id обязателен';
END IF;
IF 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;
IF p_parent_id IS NOT NULL AND
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
END IF;
-- INSERT project (CHECK-ы name/project_type/vsp_format/placement_type
-- из migrate_form3_v1.sql применятся автоматически).
INSERT INTO v3.project (
name, level, parent_id, org_unit_id,
project_type, vsp_format, placement_type, object_address, staff_count, total_area,
system_code
) VALUES (
p_name, p_level, p_parent_id, p_org_unit_id,
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area,
p_system_code
) RETURNING id INTO v_pid;
-- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей.
-- p_emit_audit=false — событие года покрыто PROJECT_CREATE ниже. Дальнейшие
-- годы добавляются отдельно через v3.add_project_year.
SELECT y.limit_report_id, y.current_expenses_report_id
INTO v_lim, v_cur
FROM v3.add_project_year(v_pid, p_year, false) y;
-- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT_CREATE',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'year', p_year,
'level', p_level,
'parent_id', p_parent_id,
'org_unit_id', p_org_unit_id,
'project_type', p_project_type,
'vsp_format', p_vsp_format,
'placement_type', p_placement_type,
'object_address', p_object_address,
'system_code', p_system_code,
'staff_count', p_staff_count,
'total_area', p_total_area,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
))
);
RETURN QUERY SELECT v_pid, v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.v_form3_report_sections — отображение листа ЛП (LIMIT) или ТР (CURRENT)
-- ════════════════════════════════════════════════════════════════════════════
-- Возвращает иерархию (ROOT/GROUP/ITEM/SUB_ITEM) + INPUT строки в tree-order
-- для одного rf_project_report (один трек: report_type = LIMIT либо
-- CURRENT_EXPENSES). UI выбирает трек, передавая report_id соответствующей
-- записи rf_project_report.
--
-- Колоночная раскладка повторяет шаблон Формы 3:
-- header, q1, q2, q3, q4, year_totals.
--
-- Q1: adj_by_items, adj_increase, total_corr, m1, m2, m3, quarter_actual, economy
-- Q2/Q3: carryover, adj_by_items, adj_increase, total_corr, m1..m3, quarter_actual, economy
-- Q4: carryover, adj_by_items, adj_increase, total_corr, m1..m3, spod, quarter_actual, economy
-- year_totals: total_year_corr, total_year_actual, economy_year
--
-- p_sections — массив включаемых блоков (NULL = все). Допустимые имена:
-- 'q1', 'q2', 'q3', 'q4', 'year'
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3.v_form3_report_sections(INT, TEXT[]);
CREATE FUNCTION v3.v_form3_report_sections(
p_report_id INT,
p_sections TEXT[] DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
line_id INT,
col_section_code VARCHAR,
col_item_id VARCHAR,
col_num_group_id VARCHAR,
col_name VARCHAR,
-- Q1
col_q1_adj_by_items NUMERIC,
col_q1_adj_increase NUMERIC,
col_q1_total_corr NUMERIC,
col_q1_m1 NUMERIC,
col_q1_m2 NUMERIC,
col_q1_m3 NUMERIC,
col_q1_quarter_actual NUMERIC,
col_q1_economy NUMERIC,
-- Q2
col_q2_carryover NUMERIC,
col_q2_adj_by_items NUMERIC,
col_q2_adj_increase NUMERIC,
col_q2_total_corr NUMERIC,
col_q2_m1 NUMERIC,
col_q2_m2 NUMERIC,
col_q2_m3 NUMERIC,
col_q2_quarter_actual NUMERIC,
col_q2_economy NUMERIC,
-- Q3
col_q3_carryover NUMERIC,
col_q3_adj_by_items NUMERIC,
col_q3_adj_increase NUMERIC,
col_q3_total_corr NUMERIC,
col_q3_m1 NUMERIC,
col_q3_m2 NUMERIC,
col_q3_m3 NUMERIC,
col_q3_quarter_actual NUMERIC,
col_q3_economy NUMERIC,
-- Q4
col_q4_carryover NUMERIC,
col_q4_adj_by_items NUMERIC,
col_q4_adj_increase NUMERIC,
col_q4_total_corr NUMERIC,
col_q4_m1 NUMERIC,
col_q4_m2 NUMERIC,
col_q4_m3 NUMERIC,
col_q4_spod NUMERIC,
col_q4_quarter_actual NUMERIC,
col_q4_economy NUMERIC,
-- year totals
col_year_total_corr NUMERIC,
col_year_total_actual NUMERIC,
col_year_economy NUMERIC,
-- план-блоки (v2): Базовый план (q1q4 + Год) и Скоррект план (q2q4)
col_q1_base_plan NUMERIC,
col_q2_base_plan NUMERIC,
col_q3_base_plan NUMERIC,
col_q4_base_plan NUMERIC,
col_year_base_plan NUMERIC,
col_q2_corrected_plan NUMERIC,
col_q3_corrected_plan NUMERIC,
col_q4_corrected_plan NUMERIC,
_sort_path INT[]
)
AS $function$
DECLARE
s_q1 BOOLEAN := p_sections IS NULL OR 'q1' = ANY(p_sections);
s_q2 BOOLEAN := p_sections IS NULL OR 'q2' = ANY(p_sections);
s_q3 BOOLEAN := p_sections IS NULL OR 'q3' = ANY(p_sections);
s_q4 BOOLEAN := p_sections IS NULL OR 'q4' = ANY(p_sections);
s_year BOOLEAN := p_sections IS NULL OR 'year' = ANY(p_sections);
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
RETURN QUERY
WITH
-- Дерево expense_item, ограниченное FORM_3 (через junction)
tw AS (
SELECT t.* FROM v3.mv_expense_item_tree t
JOIN v3.expense_item_form_type eft
ON eft.expense_item_id = t.id AND eft.form_type_code = 'FORM_3'
),
-- INPUT строки: одна на v3.rf_project_report_line
page AS (
SELECT l.id AS lid, l.expense_item_id AS eid,
t.parent_item_id AS sc, -- section-код родителя в дереве (1.01.1.)
ei.item_id AS ic,
ei.num_group_id AS ng, ei.name AS ename,
t.path AS tree_path
FROM v3.rf_project_report_line l
JOIN v3.expense_item ei ON ei.id = l.expense_item_id
JOIN tw t ON t.id = l.expense_item_id
WHERE l.rf_project_report_id = p_report_id
),
-- Поквартальные данные
jq AS (
SELECT q.rf_project_report_line_id AS lid, q.quarter,
q.adj_by_items, q.adj_increase,
q.actual_m1, q.actual_m2, q.actual_m3, q.actual_spod,
q.base_plan, q.corrected_plan
FROM v3.rf_project_report_quarter q
JOIN v3.rf_project_report_line l ON l.id = q.rf_project_report_line_id
WHERE l.rf_project_report_id = p_report_id
),
-- INPUT base: соединяем page + 4 квартала
input_base AS (
SELECT
pg.lid, pg.eid, pg.sc, pg.ic, pg.ng, pg.ename, pg.tree_path,
q1.adj_by_items AS q1_abi, q1.adj_increase AS q1_ai,
q1.actual_m1 AS q1_m1, q1.actual_m2 AS q1_m2, q1.actual_m3 AS q1_m3,
q2.adj_by_items AS q2_abi, q2.adj_increase AS q2_ai,
q2.actual_m1 AS q2_m1, q2.actual_m2 AS q2_m2, q2.actual_m3 AS q2_m3,
q3.adj_by_items AS q3_abi, q3.adj_increase AS q3_ai,
q3.actual_m1 AS q3_m1, q3.actual_m2 AS q3_m2, q3.actual_m3 AS q3_m3,
q4.adj_by_items AS q4_abi, q4.adj_increase AS q4_ai,
q4.actual_m1 AS q4_m1, q4.actual_m2 AS q4_m2, q4.actual_m3 AS q4_m3,
q4.actual_spod AS q4_spod,
q1.base_plan AS q1_bp, q2.base_plan AS q2_bp,
q3.base_plan AS q3_bp, q4.base_plan AS q4_bp,
q2.corrected_plan AS q2_cp, q3.corrected_plan AS q3_cp, q4.corrected_plan AS q4_cp
FROM page pg
LEFT JOIN jq q1 ON q1.lid = pg.lid AND q1.quarter = 1
LEFT JOIN jq q2 ON q2.lid = pg.lid AND q2.quarter = 2
LEFT JOIN jq q3 ON q3.lid = pg.lid AND q3.quarter = 3
LEFT JOIN jq q4 ON q4.lid = pg.lid AND q4.quarter = 4
),
-- Computed totals per row
enriched AS (
SELECT b.*,
COALESCE(b.q1_abi,0) + COALESCE(b.q1_ai,0) AS q1_tc,
COALESCE(b.q1_m1,0) + COALESCE(b.q1_m2,0) + COALESCE(b.q1_m3,0) AS q1_qa,
COALESCE(b.q2_abi,0) + COALESCE(b.q2_ai,0) AS q2_tc,
COALESCE(b.q2_m1,0) + COALESCE(b.q2_m2,0) + COALESCE(b.q2_m3,0) AS q2_qa,
COALESCE(b.q3_abi,0) + COALESCE(b.q3_ai,0) AS q3_tc,
COALESCE(b.q3_m1,0) + COALESCE(b.q3_m2,0) + COALESCE(b.q3_m3,0) AS q3_qa,
COALESCE(b.q4_abi,0) + COALESCE(b.q4_ai,0) AS q4_tc,
COALESCE(b.q4_m1,0) + COALESCE(b.q4_m2,0) + COALESCE(b.q4_m3,0) + COALESCE(b.q4_spod,0) AS q4_qa
FROM input_base b
),
-- Per-line aggregates for tree rollup
agg_q AS (
SELECT l.expense_item_id AS eid, q.quarter,
SUM(q.adj_by_items) AS abi, SUM(q.adj_increase) AS ai,
SUM(q.actual_m1) AS m1, SUM(q.actual_m2) AS m2, SUM(q.actual_m3) AS m3,
SUM(q.actual_spod) AS spod,
SUM(q.base_plan) AS bp, SUM(q.corrected_plan) AS cp
FROM v3.rf_project_report_line l
JOIN v3.rf_project_report_quarter q ON q.rf_project_report_line_id = l.id
WHERE l.rf_project_report_id = p_report_id
GROUP BY l.expense_item_id, q.quarter
),
tw_aq1 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 1
GROUP BY tw.id
),
tw_aq2 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 2
GROUP BY tw.id
),
tw_aq3 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 3
GROUP BY tw.id
),
tw_aq4 AS (
SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.spod) AS spod,
SUM(a.bp) AS bp, SUM(a.cp) AS cp
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter = 4
GROUP BY tw.id
)
-- ═══ Часть A: INPUT строки ════════════════════════════════════════════════
SELECT * FROM (
SELECT
'INPUT'::VARCHAR AS row_type, 3 AS depth, e.lid::INT AS line_id,
e.sc, e.ic, e.ng, e.ename,
-- Q1
CASE WHEN s_q1 THEN e.q1_abi END,
CASE WHEN s_q1 THEN e.q1_ai END,
CASE WHEN s_q1 THEN e.q1_tc END,
CASE WHEN s_q1 THEN e.q1_m1 END,
CASE WHEN s_q1 THEN e.q1_m2 END,
CASE WHEN s_q1 THEN e.q1_m3 END,
CASE WHEN s_q1 THEN e.q1_qa END,
CASE WHEN s_q1 THEN ROUND(e.q1_tc - e.q1_qa, 1) END,
-- Q2 (carryover пока NULL — нет столбца в _quarter; задел на будущее)
CASE WHEN s_q2 THEN NULL::NUMERIC END,
CASE WHEN s_q2 THEN e.q2_abi END,
CASE WHEN s_q2 THEN e.q2_ai END,
CASE WHEN s_q2 THEN e.q2_tc END,
CASE WHEN s_q2 THEN e.q2_m1 END,
CASE WHEN s_q2 THEN e.q2_m2 END,
CASE WHEN s_q2 THEN e.q2_m3 END,
CASE WHEN s_q2 THEN e.q2_qa END,
CASE WHEN s_q2 THEN ROUND(e.q2_tc - e.q2_qa, 1) END,
-- Q3
CASE WHEN s_q3 THEN NULL::NUMERIC END,
CASE WHEN s_q3 THEN e.q3_abi END,
CASE WHEN s_q3 THEN e.q3_ai END,
CASE WHEN s_q3 THEN e.q3_tc END,
CASE WHEN s_q3 THEN e.q3_m1 END,
CASE WHEN s_q3 THEN e.q3_m2 END,
CASE WHEN s_q3 THEN e.q3_m3 END,
CASE WHEN s_q3 THEN e.q3_qa END,
CASE WHEN s_q3 THEN ROUND(e.q3_tc - e.q3_qa, 1) END,
-- Q4
CASE WHEN s_q4 THEN NULL::NUMERIC END,
CASE WHEN s_q4 THEN e.q4_abi END,
CASE WHEN s_q4 THEN e.q4_ai END,
CASE WHEN s_q4 THEN e.q4_tc END,
CASE WHEN s_q4 THEN e.q4_m1 END,
CASE WHEN s_q4 THEN e.q4_m2 END,
CASE WHEN s_q4 THEN e.q4_m3 END,
CASE WHEN s_q4 THEN e.q4_spod END,
CASE WHEN s_q4 THEN e.q4_qa END,
CASE WHEN s_q4 THEN ROUND(e.q4_tc - e.q4_qa, 1) END,
-- year
CASE WHEN s_year THEN e.q1_tc + e.q2_tc + e.q3_tc + e.q4_tc END,
CASE WHEN s_year THEN e.q1_qa + e.q2_qa + e.q3_qa + e.q4_qa END,
CASE WHEN s_year THEN ROUND((e.q1_tc + e.q2_tc + e.q3_tc + e.q4_tc) -
(e.q1_qa + e.q2_qa + e.q3_qa + e.q4_qa), 1) END,
-- план-блоки
CASE WHEN s_q1 THEN e.q1_bp END,
CASE WHEN s_q2 THEN e.q2_bp END,
CASE WHEN s_q3 THEN e.q3_bp END,
CASE WHEN s_q4 THEN e.q4_bp END,
CASE WHEN s_year THEN COALESCE(e.q1_bp,0)+COALESCE(e.q2_bp,0)+
COALESCE(e.q3_bp,0)+COALESCE(e.q4_bp,0) END,
CASE WHEN s_q2 THEN e.q2_cp END,
CASE WHEN s_q3 THEN e.q3_cp END,
CASE WHEN s_q4 THEN e.q4_cp END,
e.tree_path || ARRAY[e.lid::INT] AS _sort_path
FROM enriched e
UNION ALL
-- ═══ Часть B: Иерархия (ROOT / GROUP / ITEM / SUB_ITEM) ═════════════════
SELECT
CASE t.depth WHEN 0 THEN 'ROOT' WHEN 1 THEN 'GROUP'
WHEN 2 THEN 'ITEM' WHEN 3 THEN 'SUB_ITEM' END::VARCHAR,
t.depth, NULL::INT,
CASE WHEN t.depth < 3 THEN t.item_id ELSE t.parent_item_id END,
CASE WHEN t.depth = 3 THEN t.item_id ELSE NULL END,
t.num_group_id, t.name,
-- Q1
CASE WHEN s_q1 THEN a1.abi END,
CASE WHEN s_q1 THEN a1.ai END,
CASE WHEN s_q1 THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0) END,
CASE WHEN s_q1 THEN a1.m1 END,
CASE WHEN s_q1 THEN a1.m2 END,
CASE WHEN s_q1 THEN a1.m3 END,
CASE WHEN s_q1 THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0) END,
CASE WHEN s_q1 THEN ROUND((COALESCE(a1.abi,0)+COALESCE(a1.ai,0)) -
(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)), 1) END,
-- Q2
CASE WHEN s_q2 THEN NULL::NUMERIC END,
CASE WHEN s_q2 THEN a2.abi END,
CASE WHEN s_q2 THEN a2.ai END,
CASE WHEN s_q2 THEN COALESCE(a2.abi,0)+COALESCE(a2.ai,0) END,
CASE WHEN s_q2 THEN a2.m1 END,
CASE WHEN s_q2 THEN a2.m2 END,
CASE WHEN s_q2 THEN a2.m3 END,
CASE WHEN s_q2 THEN COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0) END,
CASE WHEN s_q2 THEN ROUND((COALESCE(a2.abi,0)+COALESCE(a2.ai,0)) -
(COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)), 1) END,
-- Q3
CASE WHEN s_q3 THEN NULL::NUMERIC END,
CASE WHEN s_q3 THEN a3.abi END,
CASE WHEN s_q3 THEN a3.ai END,
CASE WHEN s_q3 THEN COALESCE(a3.abi,0)+COALESCE(a3.ai,0) END,
CASE WHEN s_q3 THEN a3.m1 END,
CASE WHEN s_q3 THEN a3.m2 END,
CASE WHEN s_q3 THEN a3.m3 END,
CASE WHEN s_q3 THEN COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0) END,
CASE WHEN s_q3 THEN ROUND((COALESCE(a3.abi,0)+COALESCE(a3.ai,0)) -
(COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)), 1) END,
-- Q4
CASE WHEN s_q4 THEN NULL::NUMERIC END,
CASE WHEN s_q4 THEN a4.abi END,
CASE WHEN s_q4 THEN a4.ai END,
CASE WHEN s_q4 THEN COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_q4 THEN a4.m1 END,
CASE WHEN s_q4 THEN a4.m2 END,
CASE WHEN s_q4 THEN a4.m3 END,
CASE WHEN s_q4 THEN a4.spod END,
CASE WHEN s_q4 THEN COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_q4 THEN ROUND((COALESCE(a4.abi,0)+COALESCE(a4.ai,0)) -
(COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)), 1) END,
-- year
CASE WHEN s_year THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+
COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+
COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_year THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_year THEN
ROUND(
(COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0))
-
(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)),
1)
END,
-- план-блоки (rollup)
CASE WHEN s_q1 THEN a1.bp END,
CASE WHEN s_q2 THEN a2.bp END,
CASE WHEN s_q3 THEN a3.bp END,
CASE WHEN s_q4 THEN a4.bp END,
CASE WHEN s_year THEN COALESCE(a1.bp,0)+COALESCE(a2.bp,0)+
COALESCE(a3.bp,0)+COALESCE(a4.bp,0) END,
CASE WHEN s_q2 THEN a2.cp END,
CASE WHEN s_q3 THEN a3.cp END,
CASE WHEN s_q4 THEN a4.cp END,
t.path
FROM tw t
LEFT JOIN tw_aq1 a1 ON a1.id = t.id
LEFT JOIN tw_aq2 a2 ON a2.id = t.id
LEFT JOIN tw_aq3 a3 ON a3.id = t.id
LEFT JOIN tw_aq4 a4 ON a4.id = t.id
) sub
ORDER BY sub._sort_path;
END;
$function$
LANGUAGE plpgsql STABLE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.upd_form3_cell / v3.upd_form3_cells — обёртки FORM_3 с проверкой роли+окна
-- ════════════════════════════════════════════════════════════════════════════
-- Параллель upd_form_cell_authz.sql, но для ветки rf_project_report. Каждая
-- ячейка прогоняется через v3.can_edit3(report_id, column, user_id). Если хоть
-- одна запрещена — RAISE EXCEPTION в формате, совместимом с конвенцией v3:
-- • role_not_allowed: <column>
-- • org_not_assigned: <column>
-- • window_closed: <column> (opens <ISO8601>) — окно ещё не наступило
-- • window_closed: <column> (closed <ISO8601>) — окно уже закрыто
-- • window_closed: <column> (no phase) — фаз вообще нет
--
-- HTTP-слой парсит префикс до `:` и мапит на 403 (см. SPEC.md §5).
--
-- ⚠ Применять СТРОГО после upd_form3_cell.sql (low-level 2-арг/4-арг). Здесь
-- добавляется p_user_id → это ДРУГИЕ сигнатуры (3-арг/5-арг), которые
-- сосуществуют с low-level и делегируют в них после проверки. HTTP вызывает
-- версии с user_id. «Листа» тут нет — report_type определяется отчётом внутри
-- can_edit3.
-- ════════════════════════════════════════════════════════════════════════════
-- ─── upd_form3_cells (массовый, с авторизацией) ────────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cells(INT, JSONB, INT);
CREATE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_column TEXT;
v_decision JSONB;
v_msg TEXT;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'role_not_allowed: <missing user_id>';
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';
IF v_column IS NULL THEN
RAISE EXCEPTION 'change must have column: %', v_change;
END IF;
v_decision := v3.can_edit3(p_report_id, 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;
-- Все ячейки разрешены — делегируем в low-level (2-арг). ROW_UPDATE не
-- логируем (см. logging_events.md): cell-edit'ы слишком частые.
RETURN QUERY
SELECT * FROM v3.upd_form3_cells(p_report_id, p_changes);
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ─── upd_form3_cell (одна ячейка, тонкий wrapper) ──────────────────────────
DROP FUNCTION IF EXISTS v3.upd_form3_cell(INT, INT, TEXT, JSONB, INT);
CREATE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
)),
p_user_id
);
$function$
LANGUAGE sql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.migrate_form3_v2 — DDL под переработку FORM_3 (листы ЛП/ТР, v2)
-- ════════════════════════════════════════════════════════════════════════════
-- Источник: «Примерорма_3_new(1).xlsm» (листы шаблон_ЛП / шаблон_ТР) + PDF-дифф
-- аналитика. Финансовая часть переразбита с 4 на 10 блоков и переведена на
-- многолетнюю раскладку (год1/год2/год3 рядом, кварталы повторяются на год).
--
-- Новые блоки внутри одного года:
-- • Базовый план по Лимиту Правления — ручной ввод q1q4, Год = Σ кв.
-- • Скоррект план по Лимиту Правления — ручной ввод q2q4 (без q1, без Года)
-- • Фактические затраты — уже покрыты rf_project_report_quarter
-- (adj_by_items / adj_increase / actual_m1..m3 / actual_spod), «Остаток» и
-- квартальные/годовые итоги считаются на лету в read-функции.
--
-- Многолетность уже поддержана моделью: один project → много rf_project_report
-- по (year × report_type). Здесь добавляется ТОЛЬКО хранение план-блоков и
-- «Системный код ВСП» в шапке проекта.
-- ════════════════════════════════════════════════════════════════════════════
-- ───────────── 1. rf_project_report_quarter: план-блоки ─────────────────────
-- base_plan — «Базовый план по Лимиту Правления», поквартально (q1q4).
-- corrected_plan — «Скоррект план по Лимиту Правления», поквартально (q2q4).
-- Для q1 значения не существует (шаблон рисует скорр.план с
-- II квартала) — гарантируется CHECK ниже + write-слой/RBAC.
ALTER TABLE v3.rf_project_report_quarter
ADD COLUMN IF NOT EXISTS base_plan NUMERIC,
ADD COLUMN IF NOT EXISTS corrected_plan NUMERIC;
-- Скоррект план не заводится в I квартале (нет колонки в шаблоне).
ALTER TABLE v3.rf_project_report_quarter
DROP CONSTRAINT IF EXISTS chk_v3_rf_quarter_corr_plan_q1;
ALTER TABLE v3.rf_project_report_quarter
ADD CONSTRAINT chk_v3_rf_quarter_corr_plan_q1
CHECK (quarter <> 1 OR corrected_plan IS NULL);
-- ───────────── 2. v3.project: «Системный код ВСП» ───────────────────────────
-- Новое поле шапки «Информация о проекте ВСП» (в примере — 1808). Выбор из
-- справочника ИНФО / выпадающего списка; хранится как атрибут проекта (вводится
-- один раз, не дублируется между треками/годами — как и остальные атрибуты ВСП).
ALTER TABLE v3.project
ADD COLUMN IF NOT EXISTS system_code VARCHAR;
-- ───────────── 3. Обновление энум-списков шапки ────────────────────────────
-- В новом шаблоне списки изменились относительно старого VBA-набора.
-- project_type: новый перечень из 8 значений (шаблон_ЛП F2:F9).
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_project_type_check;
ALTER TABLE v3.project
ADD CONSTRAINT project_project_type_check
CHECK (project_type IS NULL OR project_type IN (
'Открытие ВСП','Реновация ВСП','Закрытие ВСП',
'Реновация РФ','Закрытие РФ',
'Открытие УРМ','Реновация УРМ','Закрытие УРМ'
));
-- vsp_format («Новый формат ВСП») — поле автозаполняется из справочника INFO
-- («Утвержденный формат»), не вводится вручную и его словарь ведётся как
-- reference-data. Жёсткий CHECK снимаем, чтобы новые форматы не ломали вставку.
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_vsp_format_check;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.v_form3_report_jsonb — JSONB-обёртка над v_form3_report_sections
-- ════════════════════════════════════════════════════════════════════════════
-- Один трек FORM_3 (LIMIT либо CURRENT_EXPENSES) как (row_type, depth,
-- sort_order, data jsonb) — общий формат UI с FORM_1/2/4.
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3.v_form3_report_jsonb(INT, TEXT[]);
CREATE FUNCTION v3.v_form3_report_jsonb(
p_report_id INT,
p_sections TEXT[] DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT
s.row_type,
s.depth,
ROW_NUMBER() OVER (ORDER BY s._sort_path) AS sort_order,
jsonb_build_object(
'line_id', s.line_id,
'header', jsonb_build_object(
'section_code', s.col_section_code,
'item_id', s.col_item_id,
'num_group_id', s.col_num_group_id,
'name', s.col_name,
-- expense_item_id из _sort_path: для INPUT путь = {...ei_id, line_id},
-- для иерархии = {...ei_id} → последний/предпоследний элемент.
'expense_item_id', CASE WHEN s.row_type = 'INPUT'
THEN s._sort_path[array_upper(s._sort_path,1)-1]
ELSE s._sort_path[array_upper(s._sort_path,1)] END
),
'q1', jsonb_build_object(
'base_plan', s.col_q1_base_plan,
'adj_by_items', s.col_q1_adj_by_items,
'adj_increase', s.col_q1_adj_increase,
'total_corr', s.col_q1_total_corr,
'm1', s.col_q1_m1,
'm2', s.col_q1_m2,
'm3', s.col_q1_m3,
'quarter_actual', s.col_q1_quarter_actual,
'economy', s.col_q1_economy -- «Остаток» = корр+увелич−квартал
),
'q2', jsonb_build_object(
'base_plan', s.col_q2_base_plan,
'corrected_plan', s.col_q2_corrected_plan,
'carryover', s.col_q2_carryover,
'adj_by_items', s.col_q2_adj_by_items,
'adj_increase', s.col_q2_adj_increase,
'total_corr', s.col_q2_total_corr,
'm1', s.col_q2_m1,
'm2', s.col_q2_m2,
'm3', s.col_q2_m3,
'quarter_actual', s.col_q2_quarter_actual,
'economy', s.col_q2_economy
),
'q3', jsonb_build_object(
'base_plan', s.col_q3_base_plan,
'corrected_plan', s.col_q3_corrected_plan,
'carryover', s.col_q3_carryover,
'adj_by_items', s.col_q3_adj_by_items,
'adj_increase', s.col_q3_adj_increase,
'total_corr', s.col_q3_total_corr,
'm1', s.col_q3_m1,
'm2', s.col_q3_m2,
'm3', s.col_q3_m3,
'quarter_actual', s.col_q3_quarter_actual,
'economy', s.col_q3_economy
),
'q4', jsonb_build_object(
'base_plan', s.col_q4_base_plan,
'corrected_plan', s.col_q4_corrected_plan,
'carryover', s.col_q4_carryover,
'adj_by_items', s.col_q4_adj_by_items,
'adj_increase', s.col_q4_adj_increase,
'total_corr', s.col_q4_total_corr,
'm1', s.col_q4_m1,
'm2', s.col_q4_m2,
'm3', s.col_q4_m3,
'spod', s.col_q4_spod,
'quarter_actual', s.col_q4_quarter_actual,
'economy', s.col_q4_economy
),
'totals', jsonb_build_object(
'base_plan', s.col_year_base_plan, -- Год Базового плана (Σ q1q4)
'total_corr', s.col_year_total_corr,
'total_actual', s.col_year_total_actual,
'economy', s.col_year_economy
)
) AS data
FROM v3.v_form3_report_sections(p_report_id, p_sections) s
ORDER BY s._sort_path;
$function$
LANGUAGE sql STABLE;
-- ════════════════════════════════════════════════════════════════════════════
-- 09_backfill_existing.sql — донакат на СУЩЕСТВУЮЩИЕ отчёты FORM_3
-- ════════════════════════════════════════════════════════════════════════════
-- Нужен ТОЛЬКО если в БД уже есть отчёты rf_project_report, созданные до этого
-- апдейта. На чистой БД / для новых проектов не требуется (add_project и
-- add_project_year уже дают правильные снапшоты и сетку).
--
-- 1) copy_template_to_report использует ON CONFLICT DO NOTHING → у старых
-- отчётов в form3_phase остаются СТАРЫЕ column_keys (без base_plan/
-- corrected_plan). Ресинкаем column_keys из обновлённого phase_template.
-- 2) (ОПЦИОНАЛЬНО) досеиваем фиксированную сетку статей для старых отчётов.
-- ⚠ Меняет набор строк старых отчётов (добавит все листовые статьи FORM_3).
-- Раскомментируйте, только если хотите привести старые отчёты к фикс-сетке.
-- ════════════════════════════════════════════════════════════════════════════
-- 1. Ресинк column_keys существующих снапшотов с шаблоном (только добавляет
-- новые ключи план-блоков; окна opens_at/closes_at не трогаем).
UPDATE v3.form3_phase fp
SET column_keys = pt.column_keys
FROM v3.rf_project_report r,
v3.phase_template pt
WHERE fp.rf_project_report_id = r.id
AND pt.form_type = 'FORM_3'
AND pt.sheet = r.report_type
AND pt.phase_code = fp.phase_code
AND pt.role = fp.role
AND fp.column_keys IS DISTINCT FROM pt.column_keys;
-- Также подхватить фазы, которых у старого отчёта вовсе не было (идемпотентно).
SELECT v3.copy_template_to_report(id) FROM v3.rf_project_report;
-- 2. ОПЦИОНАЛЬНО: фиксированная сетка статей для старых отчётов.
-- Раскомментируйте при необходимости:
-- SELECT v3.seed_form3_report_lines(id) FROM v3.rf_project_report;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.v_form3_project_jsonb — многолетний широкий лист ЛП/ТР одного проекта
-- ════════════════════════════════════════════════════════════════════════════
-- Новый шаблон FORM_3 (v2) показывает годы проекта рядом (год1|год2|…) —
-- «кварталы повторяются для каждого года проекта». Горизонт переменный: сколько
-- у проекта заведено годовых rf_project_report того же трека, столько year-групп.
--
-- Модель: каждый (год × трек) — отдельный rf_project_report со своим набором
-- строк (фиксированная сетка статей, одинаковая во всех годах). Здесь мы сшиваем
-- эти годовые отчёты по СТАТЬЕ (expense_item_id — стабильный ключ между годами;
-- line_id у строки в каждом году свой и лежит внутри года — для адресной правки).
--
-- Возврат — общий формат UI (row_type, depth, sort_order, data), где data:
-- { "header": {...}, -- общая шапка строки (одна на все годы)
-- "years": { "2025": <год-объект>, ... },-- год → data из v_form3_report_jsonb
-- -- (внутри line_id года + q1..q4 + totals)
-- "project_total": <NUMERIC> } -- «Стоимость проекта за N лет» =
-- -- Σ годовых фактических итогов (totals.total_actual)
--
-- p_report_type — 'LIMIT' (ЛП) либо 'CURRENT_EXPENSES' (ТР).
-- p_sections — как в v_form3_report_sections ('q1'..'q4','year'), прокидывается
-- в каждый год.
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3.v_form3_project_jsonb(INT, VARCHAR, TEXT[]);
CREATE FUNCTION v3.v_form3_project_jsonb(
p_project_id INT,
p_report_type VARCHAR,
p_sections TEXT[] DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
WITH reports AS (
SELECT r.id AS report_id, r.year
FROM v3.rf_project_report r
WHERE r.project_id = p_project_id
AND r.report_type = p_report_type
),
-- Разворачиваем каждый год в строки его листа
per_year AS (
SELECT
v.row_type,
v.depth,
(v.data->'header'->>'expense_item_id')::INT AS eiid,
v.data->'header' AS header,
v.sort_order AS y_sort,
rp.year AS year,
v.data AS ydata
FROM reports rp
CROSS JOIN LATERAL v3.v_form3_report_jsonb(rp.report_id, p_sections) v
),
-- Сшиваем по идентичности строки (row_type + статья): сетка одинакова во всех годах
agg AS (
SELECT
py.row_type,
py.depth,
py.eiid,
MIN(py.y_sort) AS sort_order,
(array_agg(py.header ORDER BY py.year))[1] AS header,
jsonb_object_agg(py.year::text, py.ydata ORDER BY py.year) AS years,
SUM((py.ydata->'totals'->>'total_actual')::numeric) AS project_total
FROM per_year py
GROUP BY py.row_type, py.depth, py.eiid
)
SELECT
a.row_type,
a.depth,
ROW_NUMBER() OVER (ORDER BY a.sort_order) AS sort_order,
jsonb_build_object(
'header', a.header,
'years', a.years,
'project_total', a.project_total
) AS data
FROM agg a
ORDER BY a.sort_order;
$function$
LANGUAGE sql STABLE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.phase_template — seed (фазы редактирования: роль × лист × окно × колонки)
-- ════════════════════════════════════════════════════════════════════════════
-- Шаблон редактируется админом. Изменения здесь НЕ влияют на уже созданные
-- формы — у них свой snapshot в v3.form_phase.
--
-- Этот seed — стартовая разметка, в которой жёстко прошита только семантика
-- sequestration / seq_ssp / seq_dfip (где actor однозначно следует из v3-кода).
-- Остальные колонки (plan, q1.adj_*, contract_*, allocation, reserve, ckk,
-- collegial, header.*, ...) НЕ покрыты — добавлять по мере согласования
-- бизнес-правила «кто правит в какую фазу».
--
-- Для smoke-теста все фазы открыты широко (весь 2026). В проде окна должны
-- соответствовать реальному регламенту бюджетного цикла.
--
-- Возвратные фазы (EXECUTOR_RF→DFIP→EXECUTOR_RF) — несколько строк с одной
-- ролью, разные phase_code и окна, без особых случаев.
--
-- ⚠ role ссылается на v3.role(code): ADMIN / DFIP / EXECUTOR_RF. ADMIN в
-- шаблоне быть не должен (в can_edit делается bypass) — CHECK-констрейнт
-- chk_v3_phase_template_no_admin отрежет.
-- ════════════════════════════════════════════════════════════════════════════
TRUNCATE TABLE v3.phase_template;
-- ─── FORM_1: actor sequestration = DFIP (alias на одно поле в v3.sequestration)
INSERT INTO v3.phase_template
(form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
VALUES
('FORM_1', 'AHR', 'DFIP_SEQ_2026', 'DFIP',
ARRAY['sequestration.q1','sequestration.q2','sequestration.q3','sequestration.q4',
'sequestration.justification'],
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00'),
('FORM_1', 'CAP', 'DFIP_SEQ_2026', 'DFIP',
ARRAY['sequestration.q1','sequestration.q2','sequestration.q3','sequestration.q4',
'sequestration.justification'],
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00'),
('FORM_1', 'OPER', 'DFIP_SEQ_2026', 'DFIP',
ARRAY['sequestration.q1','sequestration.q2','sequestration.q3','sequestration.q4',
'sequestration.justification'],
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00');
-- ─── FORM_2 / FORM_4: разнесены по actor через scope (seq_ssp / seq_dfip)
INSERT INTO v3.phase_template
(form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT ft, sh, ph, rl, ck,
'2026-01-01 00:00:00+00'::timestamptz,
'2026-12-31 23:59:59+00'::timestamptz
FROM (VALUES
('FORM_2','AHR'), ('FORM_2','CAP'), ('FORM_2','OPER'),
('FORM_4','AHR'), ('FORM_4','CAP'), ('FORM_4','OPER')
) AS f(ft, sh)
CROSS JOIN (VALUES
('SSP_SEQ_2026', 'EXECUTOR_RF', ARRAY['seq_ssp.q1','seq_ssp.q2','seq_ssp.q3','seq_ssp.q4',
'seq_ssp.justification']),
('DFIP_SEQ_2026', 'DFIP', ARRAY['seq_dfip.q1','seq_dfip.q2','seq_dfip.q3','seq_dfip.q4',
'seq_dfip.justification'])
) AS p(ph, rl, ck);
-- ─── FORM_3: sheet = report_type ('LIMIT' / 'CURRENT_EXPENSES') ────────────
-- Ветка rf_project_report. column_keys — scope.field формата qN.<field>
-- (см. upd_form3_cell.sql). Год несёт report_id (каждый год проекта — свой
-- rf_project_report), поэтому ключи БЕЗ префикса года. Наборы полей:
-- Факт: adj_by_items / adj_increase / m1 / m2 / m3 по кварталу + q4.spod
-- Базовый план: q1..q4.base_plan
-- Скоррект план: q2..q4.corrected_plan (без q1 — шаблон с II квартала)
-- EXECUTOR_RF — основной заполнитель отчётов РФ. Стартовая разметка: одна
-- широкая фаза заполнения на весь 2026 для обоих треков. Реальные окна/роли
-- (в т.ч. проверка DFIP) — уточнять по регламенту.
INSERT INTO v3.phase_template
(form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT 'FORM_3', sh, 'RF_FILL_2026', 'EXECUTOR_RF',
ARRAY[
-- Факт
'q1.adj_by_items','q1.adj_increase','q1.m1','q1.m2','q1.m3',
'q2.adj_by_items','q2.adj_increase','q2.m1','q2.m2','q2.m3',
'q3.adj_by_items','q3.adj_increase','q3.m1','q3.m2','q3.m3',
'q4.adj_by_items','q4.adj_increase','q4.m1','q4.m2','q4.m3','q4.spod',
-- Базовый план (q1q4)
'q1.base_plan','q2.base_plan','q3.base_plan','q4.base_plan',
-- Скоррект план (q2q4)
'q2.corrected_plan','q3.corrected_plan','q4.corrected_plan'
],
'2026-01-01 00:00:00+00'::timestamptz,
'2026-12-31 23:59:59+00'::timestamptz
FROM (VALUES ('LIMIT'), ('CURRENT_EXPENSES')) AS s(sh);
-- Образец возвратной DFIP-фазы (ГО правит корректировки после РФ):
-- INSERT INTO v3.phase_template
-- (form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
-- VALUES ('FORM_3', 'CURRENT_EXPENSES', 'DFIP_REVIEW_2026', 'DFIP',
-- ARRAY['q1.adj_by_items','q1.adj_increase','q2.adj_by_items','q2.adj_increase'],
-- '2026-04-01 00:00:00+00', '2026-04-30 23:59:59+00');
-- ─── TODO: phase для plan, q1.adj_*, contract, allocation, reserve, etc. ───
-- Образец на будущее — план EXECUTOR_RF в первое окно цикла:
--
-- INSERT INTO v3.phase_template
-- (form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
-- VALUES (
-- 'FORM_1', 'AHR', 'SSP_PLAN_INITIAL_2026', 'EXECUTOR_RF',
-- ARRAY['plan.q1','plan.q2','plan.q3','plan.q4','plan.comment'],
-- '2026-02-01 00:00:00+00', '2026-02-28 23:59:59+00'
-- );
--
-- Полный список scope.field — см. v3/upd_form_cell.sql (комментарий вверху
-- файла + блоки в _apply_form_cell).