1282 lines
56 KiB
PL/PgSQL
Raw 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.

-- ════════════════════════════════════════════════════════════════════════════
-- МИГРАЦИЯ: Добавление поля direction в phase_template и form_phase
-- ════════════════════════════════════════════════════════════════════════════
-- Добавляем direction
ALTER TABLE v3.phase_template ADD COLUMN direction VARCHAR;
ALTER TABLE v3.form_phase ADD COLUMN direction VARCHAR;
ALTER TABLE v3.form_phase DROP CONSTRAINT IF EXISTS form_phase_pkey;
CREATE UNIQUE INDEX form_phase_uidx ON v3.form_phase(budget_form_id, sheet, direction, phase_code);
ALTER TABLE v3.phase_template DROP CONSTRAINT IF EXISTS phase_template_pkey;
CREATE UNIQUE INDEX phase_template_uidx ON v3.phase_template(form_type, sheet, direction, phase_code);
-- Добаввляем direction во все нужные функции фаз и шаблонов фаз
DROP FUNCTION IF EXISTS v3.add_form_phase(int4, varchar, varchar, varchar, _text, timestamp without time zone, timestamp without time zone);
CREATE OR REPLACE FUNCTION v3.add_form_phase(
p_budget_form_id integer,
p_sheet character varying,
p_direction character varying,
p_phase_code character varying,
p_role character varying,
p_column_keys text[],
p_opens_at timestamp without time zone,
p_closes_at timestamp without time zone
)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
BEGIN
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit_id;
INSERT INTO v3.form_phase(
budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at
)
VALUES (
p_budget_form_id,
p_sheet,
p_direction,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'create',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'direction', p_direction,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
DROP FUNCTION IF EXISTS v3.can_edit(int4, varchar, text, int4);
CREATE OR REPLACE FUNCTION v3.can_edit(p_form_id integer, p_sheet character varying, p_direction character varying, p_column_key text, p_user_id integer)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
v_role VARCHAR;
v_active_close TIMESTAMPTZ;
v_role_has_col BOOLEAN;
v_next_open TIMESTAMPTZ;
v_last_close TIMESTAMPTZ;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', 'unknown or inactive user'
);
END IF;
-- ADMIN — bypass всех проверок.
IF v_role = 'ADMIN' THEN
RETURN jsonb_build_object('ok', true, 'admin', true);
END IF;
-- EXECUTOR_RF: фильтр по user_org формы.
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_form_org(p_user_id, p_form_id) THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'org_not_assigned',
'detail', p_column_key
);
END IF;
-- 1. Активная сейчас фаза, в которой эта колонка перечислена для этой роли?
SELECT MAX(fp.closes_at)
INTO v_active_close
FROM v3.form_phase fp
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND now() BETWEEN fp.opens_at AND fp.closes_at;
IF v_active_close IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', true,
'closes_at', v_active_close
);
END IF;
-- 2. Колонка В ПРИНЦИПЕ есть хоть в одной фазе этой роли (на этой форме/листе)?
-- Если нет — это вопрос whitelist, отдаём role_not_allowed.
SELECT EXISTS (
SELECT 1
FROM v3.form_phase fp
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
) INTO v_role_has_col;
IF NOT v_role_has_col THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', p_column_key
);
END IF;
-- 3. Колонка доступна, но окно не активно. Подсказать ближайшее.
SELECT MIN(fp.opens_at)
INTO v_next_open
FROM v3.form_phase fp
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.opens_at > now();
IF v_next_open IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'opens_at', v_next_open
);
END IF;
SELECT MAX(fp.closes_at)
INTO v_last_close
FROM v3.form_phase fp
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.closes_at <= now();
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'closes_at', v_last_close
);
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.upd_form_cells(p_form_id integer, p_sheet character varying, p_changes jsonb, p_user_id integer, p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_change JSONB;
v_column TEXT;
v_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_edit(p_form_id, p_sheet, p_direction, v_column, p_user_id);
IF NOT (v_decision->>'ok')::boolean THEN
IF v_decision->>'code' = 'role_not_allowed' THEN
RAISE EXCEPTION 'role_not_allowed: %', v_column;
ELSIF v_decision->>'code' = 'org_not_assigned' THEN
RAISE EXCEPTION 'org_not_assigned: %', v_column;
ELSIF v_decision->>'code' = 'window_closed' THEN
IF v_decision ? 'opens_at' THEN
v_msg := format('window_closed: %s (opens %s)',
v_column, v_decision->>'opens_at');
ELSIF v_decision->>'closes_at' IS NOT NULL THEN
v_msg := format('window_closed: %s (closed %s)',
v_column, v_decision->>'closes_at');
ELSE
v_msg := format('window_closed: %s (no phase)', v_column);
END IF;
RAISE EXCEPTION '%', v_msg;
ELSE
RAISE EXCEPTION '%: %', v_decision->>'code', v_column;
END IF;
END IF;
END LOOP;
-- Все ячейки разрешены — делегируем в v3.
-- ROW_UPDATE намеренно НЕ логируется (см. logging_events.md): cell-edit'ы
-- слишком частые. В audit_log пишем только структурные события
-- (ROW_CREATE / ROW_DELETE) + бизнес-операции (CREATE_TASK и т.д.).
RETURN QUERY
SELECT * FROM v3.upd_form_cells(
p_form_id, p_sheet, p_changes, p_direction, p_sections
);
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_inserted INTEGER;
v_utc_offset VARCHAR(3);
v_org_unit INTEGER;
v_year INTEGER;
BEGIN
SELECT form_type_code, org_unit_id, "year" INTO v_form_type, v_org_unit, v_year
FROM v3.budget_form
WHERE id = p_form_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form_phase
(budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_form_id, pt.sheet, pt.direction, pt.phase_code, pt.role,
pt.column_keys,
((pt.opens_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz,
((pt.closes_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = v_form_type
ON CONFLICT (budget_form_id, sheet, phase_code, direction) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
-- DROP FUNCTION v3.copy_template_to_report(int4);
CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_report_type VARCHAR;
v_inserted INTEGER;
v_org_unit INTEGER;
v_utc_offset VARCHAR(3);
v_year INTEGER;
BEGIN
SELECT report_type, org_unit_id, "year" INTO v_report_type, v_org_unit, v_year
FROM v3.rf_project_report AS rpr
INNER JOIN v3.project AS p ON p.id = rpr.project_id
WHERE rpr.id = p_report_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form3_phase
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_report_id, pt.phase_code, pt.role,
pt.column_keys,
((pt.opens_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz,
((pt.closes_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = 'FORM_3'
AND pt.sheet = v_report_type
ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
DROP FUNCTION IF EXISTS v3.del_form_phase(int4, varchar, varchar);
CREATE OR REPLACE FUNCTION v3.del_form_phase(p_budget_form_id integer, p_sheet character varying, p_direction character varying, p_phase_code character varying)
RETURNS boolean
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
BEGIN
DELETE FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL))
RETURNING * INTO v_old;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'delete',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'direction', p_direction,
'phase_code', p_phase_code,
'role', v_old.role,
'column_keys', v_old.column_keys,
'opens_at', v_old.opens_at,
'closes_at', v_old.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN TRUE;
END;
$function$
;
DROP FUNCTION IF EXISTS v3.editable_columns_for(int4, int4, varchar);
CREATE OR REPLACE FUNCTION v3.editable_columns_for(p_form_id integer, p_user_id integer, p_sheet character varying, p_direction character varying)
RETURNS TABLE(column_key text, closes_at timestamp with time zone)
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
v_role VARCHAR;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN; -- неизвестный/неактивный юзер → пустая маска
END IF;
-- ADMIN: вернуть UNION всех column_keys всех phase'ов на форме/листе.
IF v_role = 'ADMIN' THEN
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
GROUP BY ck;
RETURN;
END IF;
-- EXECUTOR_RF без назначения → пустая маска (default deny).
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_form_org(p_user_id, p_form_id) THEN
RETURN;
END IF;
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.budget_form_id = p_form_id
AND fp.sheet = p_sheet
AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL))
AND fp.role = v_role
AND now() BETWEEN fp.opens_at AND fp.closes_at
GROUP BY ck;
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.v_form_view(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[], p_direction character varying DEFAULT NULL::character varying, p_user_id integer DEFAULT NULL::integer)
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
v_form_type VARCHAR;
v_year INT;
v_org_unit_id INT;
BEGIN
SELECT form_type_code, year, org_unit_id
INTO v_form_type, v_year, v_org_unit_id
FROM v3.budget_form
WHERE id = p_form_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'budget_form id=% не существует в v3.budget_form', p_form_id;
END IF;
IF p_user_id IS NOT NULL THEN
RETURN QUERY
SELECT
'META'::VARCHAR,
-1,
0::BIGINT,
jsonb_build_object(
'user_id', p_user_id,
'role', v3.user_role_code(p_user_id),
'sheet', p_sheet,
'editable', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object('column', ec.column_key,
'closes_at', ec.closes_at)
ORDER BY ec.column_key)
FROM v3.editable_columns_for(p_form_id, p_user_id, p_sheet, p_direction) ec),
'[]'::JSONB)
);
END IF;
IF v_form_type = 'FORM_1' THEN
IF p_sheet = 'SMETA' THEN
RETURN QUERY
SELECT
sm.row_type,
sm.depth,
ROW_NUMBER() OVER (ORDER BY sm.section_code) AS sort_order,
jsonb_build_object(
'section_code', sm.section_code,
'name', sm.name,
'plan', jsonb_build_object(
'support', jsonb_build_object('q1', sm.supp_plan_q1, 'q2', sm.supp_plan_q2,
'q3', sm.supp_plan_q3, 'q4', sm.supp_plan_q4,
'year', sm.supp_plan_year),
'development', jsonb_build_object('q1', sm.dev_plan_q1, 'q2', sm.dev_plan_q2,
'q3', sm.dev_plan_q3, 'q4', sm.dev_plan_q4,
'year', sm.dev_plan_year),
'total_year', sm.total_plan_year),
'approved', jsonb_build_object(
'support', jsonb_build_object('q1', sm.supp_appr_q1, 'q2', sm.supp_appr_q2,
'q3', sm.supp_appr_q3, 'q4', sm.supp_appr_q4,
'year', sm.supp_appr_year),
'development', jsonb_build_object('q1', sm.dev_appr_q1, 'q2', sm.dev_appr_q2,
'q3', sm.dev_appr_q3, 'q4', sm.dev_appr_q4,
'year', sm.dev_appr_year),
'total_year', sm.total_appr_year),
'fact', jsonb_build_object(
'support', jsonb_build_object('q1', sm.supp_act_q1, 'q2', sm.supp_act_q2,
'q3', sm.supp_act_q3, 'q4', sm.supp_act_q4,
'year', sm.supp_act_year),
'development', jsonb_build_object('q1', sm.dev_act_q1, 'q2', sm.dev_act_q2,
'q3', sm.dev_act_q3, 'q4', sm.dev_act_q4,
'year', sm.dev_act_year),
'total_year', sm.total_act_year),
'corrected', jsonb_build_object(
'support', jsonb_build_object('q2', sm.supp_corr_q2, 'q3', sm.supp_corr_q3, 'q4', sm.supp_corr_q4),
'development', jsonb_build_object('q2', sm.dev_corr_q2, 'q3', sm.dev_corr_q3, 'q4', sm.dev_corr_q4))
)
FROM v3.v_form1_smeta(v_year, v_org_unit_id) sm
ORDER BY sm.section_code;
RETURN;
END IF;
IF p_sheet IN ('AHR','CAP','OPER') THEN
RETURN QUERY
SELECT
j.row_type,
j.depth,
ROW_NUMBER() OVER (ORDER BY j._sort_path) AS sort_order,
jsonb_build_object(
'line_id', j.line_id,
'header', j.header,
'plan', j.plan_data,
'contract_summary', j.contract_summary,
'allocation', j.allocation_data,
'sequestration', j.sequestration_data,
'reserve', j.reserve_data,
'approved', j.approved_data,
'collegial', j.collegial_data,
'ckk', j.ckk_data,
'contract_detail', j.contract_detail,
'q1', j.q1_data,
'q2', j.q2_data,
'q3', j.q3_data,
'q4', j.q4_data,
'totals', j.totals_data
)
FROM v3.v_form1_sheet_jsonb(p_form_id, p_sheet, p_direction, p_sections) j
ORDER BY j._sort_path;
RETURN;
END IF;
RAISE EXCEPTION 'Unknown FORM_1 sheet: %', p_sheet
USING HINT = 'Use AHR / CAP / OPER / SMETA';
END IF;
IF v_form_type = 'FORM_2' THEN
RETURN QUERY SELECT * FROM v3.v_form2_view(p_form_id, p_sheet, p_sections);
RETURN;
END IF;
IF v_form_type = 'FORM_4' THEN
RETURN QUERY SELECT * FROM v3.v_form4_view(p_form_id, p_sheet, p_sections);
RETURN;
END IF;
RAISE EXCEPTION 'Unsupported form_type=% for form_id=%', v_form_type, p_form_id;
END;
$function$
;
DROP FUNCTION IF EXISTS v3.upd_form_phase(int4, varchar, varchar, varchar, _text, timestamp, timestamp);
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_direction character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_new v3.form_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
BEGIN
SELECT * INTO v_old
FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL))
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
p_budget_form_id, p_sheet, p_phase_code;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL))
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
p_budget_form_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'changes', v_changes
),
p_budget_form_id, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$
;
-- Обновляем direction для фаз первой формы
UPDATE v3.phase_template pt
SET direction = 'Support'
WHERE sheet IN ('AHR', 'CAP')
AND form_type = 'FORM_1';
INSERT INTO v3.phase_template
(form_type, sheet, direction, phase_code, role, column_keys, opens_at, closes_at)
SELECT pt.form_type, pt.sheet, 'Development', pt.phase_code, pt.role,
pt.column_keys, pt.opens_at,
pt.closes_at
FROM v3.phase_template pt
WHERE pt.sheet IN ('AHR', 'CAP')
AND pt.form_type = 'FORM_1';
UPDATE v3.form_phase fp
SET direction = 'Support'
WHERE sheet IN ('AHR', 'CAP')
AND budget_form_id IN (
SELECT id FROM v3.budget_form WHERE form_type_code = 'FORM_1'
);
INSERT INTO v3.form_phase
(budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at)
SELECT fp.budget_form_id, fp.sheet, 'Development', fp.phase_code, fp.role,
fp.column_keys, fp.opens_at, fp.closes_at
FROM v3.form_phase fp
WHERE fp.sheet IN ('AHR', 'CAP')
AND budget_form_id IN (
SELECT id FROM v3.budget_form WHERE form_type_code = 'FORM_1'
);
-- Добавляем возможность менять justification у budget_line
CREATE OR REPLACE FUNCTION v3._apply_form_cell(p_line_id integer, p_column text, p_value jsonb, p_sheet character varying DEFAULT NULL::character varying, p_form_id integer DEFAULT NULL::integer)
RETURNS void
LANGUAGE plpgsql
AS $function$
DECLARE
v_parts TEXT[];
v_scope TEXT;
v_field TEXT;
v_q SMALLINT;
v_actor TEXT;
v_target_table TEXT;
v_target_col TEXT;
v_target_type TEXT;
v_key_kind TEXT;
v_str TEXT;
v_sql TEXT;
v_sat_table TEXT;
v_cnt INT;
BEGIN
v_parts := string_to_array(p_column, '.');
IF p_sheet = 'OTCH9F' THEN
DECLARE v_far_month SMALLINT;
BEGIN
SELECT month INTO v_far_month FROM v3.fixed_asset_report WHERE id = p_line_id;
IF v_far_month IS NULL THEN
RAISE EXCEPTION 'fixed_asset_report.id=% не существует', p_line_id;
END IF;
IF array_length(v_parts,1) = 1 THEN
IF v_parts[1] = 'equipment_name' THEN
RAISE EXCEPTION 'normative_field: equipment_name (нормативная номенклатура, правится отдельным API)';
ELSIF v_parts[1] IN ('month','expense_item_id','item_id','section_code','id') THEN
RAISE EXCEPTION 'key_field: % (часть ключа строки, RO)', v_parts[1];
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
END IF;
IF array_length(v_parts,1) <> 2 THEN
RAISE EXCEPTION 'bad_column_format: %', p_column;
END IF;
IF v_parts[1] = 'totals' THEN
IF v_parts[2] = 'go_balance_only' THEN
v_target_col := 'go_balance_only_amt'; v_target_type := 'NUMERIC';
ELSIF v_parts[2] IN ('acquired_total','disposed_total','balance_qty','balance_amt') THEN
RAISE EXCEPTION 'computed_field: totals.%', v_parts[2];
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
ELSIF v_parts[1] IN ('b_604','b_60415') THEN
IF v_parts[2] LIKE 'opening_%' AND v_far_month <> 1 THEN
RAISE EXCEPTION 'computed_field: % (opening для month>1 вычисляется из base + накопит. дельты)', p_column;
END IF;
IF v_parts[1] = 'b_604' THEN
v_target_col := CASE v_parts[2]
WHEN 'opening_qty' THEN 'opening_qty_604'
WHEN 'opening_amt' THEN 'opening_amt_604'
WHEN 'acquired_qty' THEN 'acquired_qty_604'
WHEN 'acquired_amt' THEN 'acquired_amt_604'
WHEN 'disposed_qty' THEN 'disposed_qty_604'
WHEN 'disposed_amt' THEN 'disposed_amt_604'
END;
IF v_target_col IS NULL THEN
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END;
ELSE -- b_60415
v_target_col := CASE v_parts[2]
WHEN 'opening_qty' THEN 'opening_qty_60415'
WHEN 'opening_amt' THEN 'opening_amt_60415'
WHEN 'acquired_qty' THEN 'acquired_qty_60415'
WHEN 'acquired_amt' THEN 'acquired_amt_60415'
WHEN 'transferred_qty' THEN 'transferred_qty_60415'
WHEN 'transferred_amt' THEN 'transferred_amt_60415'
END;
IF v_target_col IS NULL THEN
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END;
END IF;
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
EXECUTE format(
'UPDATE v3.fixed_asset_report SET %I = ($2 #>> ''{}'')::%s WHERE id = $1',
v_target_col, v_target_type
) USING p_line_id, p_value;
RETURN;
END;
END IF;
IF p_sheet = 'AHR_LIMIT' THEN
IF p_form_id IS NULL THEN
RAISE EXCEPTION 'internal: p_form_id не передан для AHR_LIMIT';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.limit_template lt
WHERE lt.id = p_line_id AND lt.row_type = 'LEAF') THEN
RAISE EXCEPTION 'limit_template.id=% не существует или не LEAF (SECTION/GROUP не редактируется)', p_line_id;
END IF;
IF array_length(v_parts,1) <> 1 THEN
RAISE EXCEPTION 'bad_column_format: %, expected single key', p_column;
END IF;
CASE v_parts[1]
WHEN 'qty_q1','qty_q2','qty_q3','qty_q4' THEN
v_target_col := v_parts[1]; v_target_type := 'INTEGER';
WHEN 'comment' THEN
v_target_col := 'comment'; v_target_type := 'TEXT';
WHEN 'amount_q1','amount_q2','amount_q3','amount_q4' THEN
RAISE EXCEPTION 'computed_field: % (вычисляется из qty × limit × period_factor)', p_column;
WHEN 'name','unit','section_no','expense_item_code',
'limit_with_vat','limit_without_vat' THEN
RAISE EXCEPTION 'normative_field: % (нормативный справочник, правится админом отдельно)', p_column;
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
EXECUTE format(
'INSERT INTO v3.form_limit (budget_form_id, template_id, %1$I) '
|| 'VALUES ($1, $2, ($3 #>> ''{}'')::%2$s) '
|| 'ON CONFLICT (budget_form_id, template_id) DO UPDATE SET %1$I = EXCLUDED.%1$I',
v_target_col, v_target_type
) USING p_form_id, p_line_id, p_value;
RETURN;
END IF;
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
v_sat_table := CASE p_sheet
WHEN 'AHR_RENT' THEN 'rent_detail'
WHEN 'AHR_UTILITY' THEN 'utility_detail'
WHEN 'AHR_SECURITY' THEN 'security_detail'
END;
IF array_length(v_parts,1) = 1 THEN
CASE v_parts[1]
WHEN 'contract_number' THEN v_target_col := 'contract_number'; v_target_type := 'TEXT';
WHEN 'contract_end_date' THEN v_target_col := 'contract_end_date'; v_target_type := 'DATE';
WHEN 'comment' THEN v_target_col := 'comment'; v_target_type := 'TEXT';
WHEN 'address','object_type','rented_area','object_area' THEN
RAISE EXCEPTION 'computed_field: % (атрибут v3.vsp, правится отдельно)', p_column;
ELSE RAISE EXCEPTION 'unknown_column: %', p_column;
END CASE;
ELSIF array_length(v_parts,1) = 2 AND v_parts[1] = 'plan' THEN
IF v_parts[2] = 'year' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF;
IF v_parts[2] NOT IN ('q1','q2','q3','q4') THEN RAISE EXCEPTION 'unknown_column: %', p_column; END IF;
v_target_col := 'plan_' || v_parts[2];
v_target_type := 'NUMERIC';
ELSIF array_length(v_parts,1) = 2 AND v_parts[1] LIKE 'fact_q%' THEN
IF v_parts[2] = 'total' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF;
IF (v_parts[1]='fact_q1' AND v_parts[2] NOT IN ('jan','feb','mar')) OR
(v_parts[1]='fact_q2' AND v_parts[2] NOT IN ('apr','may','jun')) OR
(v_parts[1]='fact_q3' AND v_parts[2] NOT IN ('jul','aug','sep')) OR
(v_parts[1]='fact_q4' AND v_parts[2] NOT IN ('oct','nov','dec')) THEN
RAISE EXCEPTION 'unknown_column: % (месяц вне квартала)', p_column;
END IF;
v_target_col := 'actual_' || v_parts[2];
v_target_type := 'NUMERIC';
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
EXECUTE format(
'UPDATE v3.%I SET %I = ($2 #>> ''{}'')::%s WHERE id = $1',
v_sat_table, v_target_col, v_target_type
) USING p_line_id, p_value;
GET DIAGNOSTICS v_cnt = ROW_COUNT;
IF v_cnt = 0 THEN
RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_line_id;
END IF;
RETURN;
END IF;
IF array_length(v_parts, 1) < 2 THEN
RAISE EXCEPTION 'bad_column_format: %, expected scope.field', p_column;
END IF;
v_scope := v_parts[1];
v_field := v_parts[2];
IF v_scope = 'header' THEN
IF v_field = 'name' THEN
UPDATE v3.budget_line SET name = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id;
ELSIF v_field = 'internal_order' THEN
UPDATE v3.budget_line SET internal_order = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id;
ELSIF v_field = 'justification' THEN
UPDATE v3.budget_line SET justification = (p_value #>> '{}')::TEXT, updated_at = now() WHERE id = p_line_id;
ELSIF v_field = 'vsp_id' THEN
UPDATE v3.budget_line SET vsp_id = (p_value #>> '{}')::INT, updated_at = now() WHERE id = p_line_id;
ELSIF v_field IN ('year','section','item_id','num_group','vsp_address') THEN
RAISE EXCEPTION 'computed_field: %', p_column;
ELSE
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
RETURN;
END IF;
IF v_scope IN ('approved','totals') THEN
RAISE EXCEPTION 'computed_field: %', p_column;
END IF;
IF v_scope = 'booking' THEN
IF array_length(v_parts, 1) <> 3 THEN
RAISE EXCEPTION 'bad_column_format: %, expected booking.y{2026|2027}.qN', p_column;
END IF;
IF v_parts[2] NOT IN ('y2026','y2027') THEN
RAISE EXCEPTION 'bad_booking_year: % (only y2026/y2027)', v_parts[2];
END IF;
IF v_parts[3] NOT IN ('q1','q2','q3','q4') THEN
RAISE EXCEPTION 'bad_booking_quarter: %', v_parts[3];
END IF;
v_target_col := CASE v_parts[2]
WHEN 'y2026' THEN 'expenses_' || v_parts[3]
WHEN 'y2027' THEN 'expenses_next_year_' || v_parts[3]
END;
v_sql := format(
'INSERT INTO v3.ckk (line_id, %1$I) VALUES ($1, ($2 #>> ''{}'')::NUMERIC) '
|| 'ON CONFLICT (line_id) DO UPDATE SET %1$I = EXCLUDED.%1$I',
v_target_col
);
EXECUTE v_sql USING p_line_id, p_value;
UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id;
RETURN;
END IF;
IF v_scope IN ('q1','q2','q3','q4') THEN
v_q := substring(v_scope FROM 2)::SMALLINT;
ELSE
v_q := NULL;
END IF;
v_actor := CASE v_scope
WHEN 'sequestration' THEN 'DFIP'
WHEN 'seq_dfip' THEN 'DFIP'
WHEN 'seq_ssp' THEN 'SSP_GO'
ELSE NULL END;
SELECT m.target_table, m.target_col, m.target_type, m.key_kind
INTO v_target_table, v_target_col, v_target_type, v_key_kind
FROM (VALUES
('plan','q1', 'plan','plan_q1','NUMERIC','line'),
('plan','q2', 'plan','plan_q2','NUMERIC','line'),
('plan','q3', 'plan','plan_q3','NUMERIC','line'),
('plan','q4', 'plan','plan_q4','NUMERIC','line'),
('plan','comment', 'plan','comment','TEXT', 'line'),
('reserve','q1', 'reserve','amount_q1','NUMERIC','line'),
('reserve','q2', 'reserve','amount_q2','NUMERIC','line'),
('reserve','q3', 'reserve','amount_q3','NUMERIC','line'),
('reserve','q4', 'reserve','amount_q4','NUMERIC','line'),
('reserve','justification','reserve','justification','TEXT','line'),
('allocation','order', 'allocation','internal_order','TEXT','line'),
('allocation','property', 'allocation','property_object','TEXT','line'),
('allocation','contract_ref', 'allocation','contract_ref','TEXT','line'),
('allocation','allocation_purpose', 'allocation','allocation_purpose','TEXT','line'),
('contract_summary','total', 'contract_summary','total_amount', 'NUMERIC','line'),
('contract_summary','counterparty', 'contract_summary','counterparty', 'TEXT', 'line'),
('contract_summary','deadline', 'contract_summary','deadline', 'TEXT', 'line'),
('contract_summary','comment', 'contract_summary','comment', 'TEXT', 'line'),
('contract_summary','future_y1', 'contract_summary','future_payments_y1', 'NUMERIC','line'),
('contract_summary','future_y2', 'contract_summary','future_payments_y2', 'NUMERIC','line'),
('contract_summary','other_ssp', 'contract_summary','other_ssp_amount', 'NUMERIC','line'),
('contract_summary','reference', 'contract_summary','reference', 'TEXT','line'),
('contract_summary','centralized_flag', 'contract_summary','centralized_flag', 'TEXT','line'),
('collegial','approved', 'collegial_approval','approved_amount', 'NUMERIC','line'),
('collegial','protocol', 'collegial_approval','protocol_reference','TEXT', 'line'),
('collegial','note', 'collegial_approval','note', 'TEXT', 'line'),
('ckk','ceiling', 'ckk','ceiling_amount', 'NUMERIC','line'),
('ckk','q1', 'ckk','expenses_q1', 'NUMERIC','line'),
('ckk','q2', 'ckk','expenses_q2', 'NUMERIC','line'),
('ckk','q3', 'ckk','expenses_q3', 'NUMERIC','line'),
('ckk','q4', 'ckk','expenses_q4', 'NUMERIC','line'),
('ckk','next_q1', 'ckk','expenses_next_year_q1', 'NUMERIC','line'),
('ckk','next_q2', 'ckk','expenses_next_year_q2', 'NUMERIC','line'),
('ckk','next_q3', 'ckk','expenses_next_year_q3', 'NUMERIC','line'),
('ckk','next_q4', 'ckk','expenses_next_year_q4', 'NUMERIC','line'),
('ckk','rf_schedule', 'ckk','rf_schedule', 'TEXT', 'line'),
('ckk','deadline', 'ckk','delivery_deadline', 'TEXT', 'line'),
('ckk','proc_plan', 'ckk','procurement_plan', 'TEXT', 'line'),
('ckk','proc_method', 'ckk','procurement_method','TEXT', 'line'),
('ckk','comment', 'ckk','comment', 'TEXT', 'line'),
('contract_detail','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'),
('contract_detail','reference', 'contract_detail','reference', 'TEXT', 'line'),
('contract_detail','addenda', 'contract_detail','addenda', 'TEXT', 'line'),
('contract_detail','subject', 'contract_detail','subject', 'TEXT', 'line'),
('contract_detail','currency', 'contract_detail','currency', 'TEXT', 'line'),
('contract_detail','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'),
('contract_detail','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'),
('contract_detail','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'),
('contract_detail','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'),
('contract_detail','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'),
('contract_detail','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'),
('contract_detail','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'),
('contract_detail','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'),
('contract_detail','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'),
('contract_detail','deadline', 'contract_detail','deadline', 'TEXT', 'line'),
('contract_detail','payment_scheme', 'contract_detail','payment_scheme','TEXT', 'line'),
('contract_detail','act', 'contract_detail','act', 'TEXT', 'line'),
('contract_detail','comment', 'contract_detail','comment', 'TEXT', 'line'),
('contract_detail','contract_date', 'contract_detail','contract_date', 'DATE', 'line'),
('contract','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'),
('contract','addenda', 'contract_detail','addenda', 'TEXT', 'line'),
('contract','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'),
('contract','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'),
('contract','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'),
('contract','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'),
('contract','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'),
('contract','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'),
('contract','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'),
('contract','reference', 'contract_detail','reference', 'TEXT', 'line'),
('contract','date', 'contract_detail','contract_date', 'DATE', 'line'),
('contract','subject', 'contract_detail','subject', 'TEXT', 'line'),
('contract','currency', 'contract_detail','currency', 'TEXT', 'line'),
('contract','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'),
('contract','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'),
('contract','deadline', 'contract_detail','deadline', 'TEXT', 'line'),
('contract','scheme', 'contract_detail','payment_scheme','TEXT', 'line'),
('contract','act', 'contract_detail','act', 'TEXT', 'line'),
('contract','comment', 'contract_detail','comment', 'TEXT', 'line'),
('sequestration','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'),
('sequestration','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'),
('sequestration','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'),
('sequestration','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'),
('sequestration','justification','sequestration','justification','TEXT', 'line_actor'),
('seq_dfip','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'),
('seq_dfip','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'),
('seq_dfip','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'),
('seq_dfip','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'),
('seq_dfip','justification','sequestration','justification','TEXT', 'line_actor'),
('seq_ssp','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'),
('seq_ssp','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'),
('seq_ssp','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'),
('seq_ssp','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'),
('seq_ssp','justification','sequestration','justification','TEXT', 'line_actor'),
('q','adj_current', 'budget_line_quarter','adj_current', 'NUMERIC','line_quarter'),
('q','adj_ssp', 'budget_line_quarter','adj_ssp', 'NUMERIC','line_quarter'),
('q','adj_rf', 'budget_line_quarter','adj_rf', 'NUMERIC','line_quarter'),
('q','adj_reserve', 'budget_line_quarter','adj_reserve', 'NUMERIC','line_quarter'),
('q','adj_comment', 'budget_line_quarter','adj_comment', 'TEXT', 'line_quarter'),
('q','target_change', 'budget_line_quarter','target_change', 'NUMERIC','line_quarter'),
('q','base_correction', 'budget_line_quarter','base_plan_correction', 'NUMERIC','line_quarter'),
('q','base_correction_comment', 'budget_line_quarter','base_plan_correction_comment','TEXT', 'line_quarter'),
('q','pay_date', 'budget_line_quarter','payment_date', 'DATE', 'line_quarter'),
('q','pay_amount', 'budget_line_quarter','payment_amount', 'NUMERIC','line_quarter'),
('q','pay_ho', 'budget_line_quarter','payment_amount_ho', 'NUMERIC','line_quarter'),
('q','pay_rf', 'budget_line_quarter','payment_amount_rf', 'NUMERIC','line_quarter'),
('q','pay_comment', 'budget_line_quarter','payment_comment', 'TEXT', 'line_quarter'),
('q','pay_act', 'budget_line_quarter','payment_act', 'TEXT', 'line_quarter'),
('q','actual_m1', 'budget_line_quarter','actual_m1', 'NUMERIC','line_quarter'),
('q','actual_m2', 'budget_line_quarter','actual_m2', 'NUMERIC','line_quarter'),
('q','actual_m3', 'budget_line_quarter','actual_m3', 'NUMERIC','line_quarter'),
('q','actual_spod', 'budget_line_quarter','actual_spod', 'NUMERIC','line_quarter'),
('q','transfer_q2', 'budget_line_quarter','transfer_to_q2', 'NUMERIC','line_quarter'),
('q','transfer_q3', 'budget_line_quarter','transfer_to_q3', 'NUMERIC','line_quarter'),
('q','transfer_q4', 'budget_line_quarter','transfer_to_q4', 'NUMERIC','line_quarter'),
('q','transfer_econ', 'budget_line_quarter','transfer_to_economy', 'NUMERIC','line_quarter'),
('q','transfer_q2_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'),
('q','transfer_q2_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'),
('q','transfer_q2_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'),
('q','transfer_q3_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'),
('q','transfer_q3_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'),
('q','transfer_q3_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'),
('q','transfer_q4_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'),
('q','transfer_q4_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'),
('q','transfer_q4_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'),
('q','transfer_next_comment', 'budget_line_quarter','transfer_next_comment', 'TEXT', 'line_quarter'),
('q','transfer_far_comment', 'budget_line_quarter','transfer_far_comment', 'TEXT', 'line_quarter'),
('q','rev_eco', 'budget_line_quarter','plan_revision_eco_change','NUMERIC','line_quarter'),
('q','rev_item', 'budget_line_quarter','plan_revision_item_adj', 'NUMERIC','line_quarter'),
('q','rev_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'),
('q','rev_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'),
('q','rev_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'),
('q','revision_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'),
('q','revision_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'),
('q','revision_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'),
('q','booking_amount', 'budget_line_quarter','booking_amount', 'NUMERIC','line_quarter')
) AS m(scope, field, target_table, target_col, target_type, key_kind)
WHERE m.scope = (CASE WHEN v_q IS NOT NULL THEN 'q' ELSE v_scope END)
AND m.field = v_field;
IF v_target_table IS NULL THEN
IF v_field IN ('corrected_plan','new_plan','actual_quarter','booking',
'rem_booking','rem_actual','residual_after_booking',
'residual_after_actual','economy','total','year') THEN
RAISE EXCEPTION 'computed_field: %', p_column;
END IF;
RAISE EXCEPTION 'unknown_column: %', p_column;
END IF;
IF v_key_kind = 'line' THEN
v_sql := format(
'INSERT INTO v3.%1$I (line_id, %2$I) VALUES ($1, ($2 #>> ''{}'')::%3$s) '
|| 'ON CONFLICT (line_id) DO UPDATE SET %2$I = EXCLUDED.%2$I',
v_target_table, v_target_col, v_target_type
);
EXECUTE v_sql USING p_line_id, p_value;
ELSIF v_key_kind = 'line_quarter' THEN
IF v_q IS NULL THEN RAISE EXCEPTION 'internal: quarter not set for %', p_column; END IF;
v_sql := format(
'INSERT INTO v3.%1$I (line_id, quarter, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) '
|| 'ON CONFLICT (line_id, quarter) DO UPDATE SET %2$I = EXCLUDED.%2$I',
v_target_table, v_target_col, v_target_type
);
EXECUTE v_sql USING p_line_id, v_q, p_value;
ELSIF v_key_kind = 'line_actor' THEN
IF v_actor IS NULL THEN RAISE EXCEPTION 'internal: actor not set for %', p_column; END IF;
v_sql := format(
'INSERT INTO v3.%1$I (line_id, actor, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) '
|| 'ON CONFLICT (line_id, actor) DO UPDATE SET %2$I = EXCLUDED.%2$I',
v_target_table, v_target_col, v_target_type
);
EXECUTE v_sql USING p_line_id, v_actor, p_value;
ELSE
RAISE EXCEPTION 'internal: unknown key_kind %', v_key_kind;
END IF;
UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id;
END;
$function$
;
UPDATE v3.phase_template
SET closes_at = closes_at - INTERVAL '2026 years', opens_at = opens_at - INTERVAL '2026 years';