diff --git a/api/alembic/versions/0005_phase_timestamps.py b/api/alembic/versions/0005_phase_timestamps.py new file mode 100644 index 0000000..2db5672 --- /dev/null +++ b/api/alembic/versions/0005_phase_timestamps.py @@ -0,0 +1,330 @@ +from alembic import op +import sqlalchemy as sa + + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute('ALTER TABLE v3.phase_template ALTER COLUMN opens_at TYPE timestamp USING opens_at::timestamp') + op.execute('ALTER TABLE v3.phase_template ALTER COLUMN closes_at TYPE timestamp USING closes_at::timestamp') + op.execute("ALTER TABLE v3.org_unit ADD COLUMN utc_offset VARCHAR(3) DEFAULT '+03' NOT NULL") + # заводим фазу с нужной таймзоной + op.execute( + """ + 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; + BEGIN + SELECT form_type_code, org_unit_id INTO v_form_type, v_org_unit + 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, phase_code, role, column_keys, opens_at, closes_at) + SELECT p_form_id, pt.sheet, pt.phase_code, pt.role, + pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz, + (pt.closes_at::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) DO NOTHING; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; + END; + $function$ + ; + """ + ) + # Создавая фазу добавляем таймзону орг юнита + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp, p_closes_at timestamp) + 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, phase_code, role, column_keys, opens_at, closes_at + ) + VALUES ( + p_budget_form_id, + p_sheet, + 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', 'form_phase', + 'entity_id', p_budget_form_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, + '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$ + ; + """ + ) + # Обнволяя фазу добавляем таймзону орг юнита + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet 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 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 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( + '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', 'form_phase', + 'entity_id', p_budget_form_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$ + ; + """ + ) + + # при обновлении таймзоны орг юнита обновляем актуальные фазы, связанные с ними + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz varchar(3), p_new_tz varchar(3)) + RETURNS integer + LANGUAGE plpgsql + AS $function$ + DECLARE + v_result integer; + BEGIN + IF p_old_tz = p_new_tz THEN + RETURN 0; + END IF; + WITH updated AS ( + UPDATE v3.form_phase + SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz, + closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz + WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id) + AND ( + opens_at >= CURRENT_TIMESTAMP + OR closes_at >= CURRENT_TIMESTAMP + ) + RETURNING * + ) + SELECT COUNT(*) INTO v_result FROM updated; + RETURN v_result; + + END; + $function$ + ; + """ + ) + # op.add_column( + # 'org_unit', + # sa.Column( + # 'utc_offset', + # sa.String(3), + # nullable=False, + # server_default='+03', + # ) + # ) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/0006_form3.py b/api/alembic/versions/0006_form3.py new file mode 100644 index 0000000..279a5a6 --- /dev/null +++ b/api/alembic/versions/0006_form3.py @@ -0,0 +1,79 @@ +import os +import re +from typing import List, Optional + +from alembic import op + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _find_dollar_tag(line: str) -> Optional[str]: + m = _DOLLAR_TAG_RE.search(line.strip()) + return m.group(0) if m else None + + +def _split_statements(sql: str) -> List[str]: + statements: List[str] = [] + current: List[str] = [] + in_dollar = False + dollar_tag: Optional[str] = None + + for line in sql.split("\n"): + stripped = line.strip() + + if not in_dollar: + tag = _find_dollar_tag(stripped) + if tag and tag.endswith("$") and tag.startswith("$"): + dollar_tag = tag + in_dollar = True + current.append(line) + continue + + if in_dollar and dollar_tag and stripped.startswith(dollar_tag): + after = stripped[len(dollar_tag):].strip() + if after == ";" or after == "": + in_dollar = False + dollar_tag = None + if after == ";": + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + if not in_dollar and stripped.rstrip().endswith(";"): + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + current.append(line) + + remaining = "\n".join(current).strip() + if remaining: + statements.append(remaining) + + return statements + + +def upgrade() -> None: + ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0006_form3.sql") + with open(ddl_path) as f: + content = f.read() + + statements = _split_statements(content) + for stmt in statements: + stripped = stmt.strip().rstrip(";").strip() + if not stripped: + continue + if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")): + continue + op.execute(stripped) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/__init__.py b/api/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/alembic/versions/sql/0006_form3.sql b/api/alembic/versions/sql/0006_form3.sql new file mode 100644 index 0000000..598097d --- /dev/null +++ b/api/alembic/versions/sql/0006_form3.sql @@ -0,0 +1,2602 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- 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): Базовый план (q1–q4 + Год) и Скоррект план (q2–q4) + 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: +-- • org_not_assigned: +-- • window_closed: (opens ) — окно ещё не наступило +-- • window_closed: (closed ) — окно уже закрыто +-- • window_closed: (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: '; + 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 рядом, кварталы повторяются на год). +-- +-- Новые блоки внутри одного года: +-- • Базовый план по Лимиту Правления — ручной ввод q1–q4, Год = Σ кв. +-- • Скоррект план по Лимиту Правления — ручной ввод q2–q4 (без 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 — «Базовый план по Лимиту Правления», поквартально (q1–q4). +-- corrected_plan — «Скоррект план по Лимиту Правления», поквартально (q2–q4). +-- Для 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): Базовый план (q1–q4 + Год) и Скоррект план (q2–q4) + 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: +-- • org_not_assigned: +-- • window_closed: (opens ) — окно ещё не наступило +-- • window_closed: (closed ) — окно уже закрыто +-- • window_closed: (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: '; + 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 рядом, кварталы повторяются на год). +-- +-- Новые блоки внутри одного года: +-- • Базовый план по Лимиту Правления — ручной ввод q1–q4, Год = Σ кв. +-- • Скоррект план по Лимиту Правления — ручной ввод q2–q4 (без 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 — «Базовый план по Лимиту Правления», поквартально (q1–q4). +-- corrected_plan — «Скоррект план по Лимиту Правления», поквартально (q2–q4). +-- Для 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, -- Год Базового плана (Σ q1–q4) + '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": } -- «Стоимость проекта за 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. +-- (см. 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', + -- Базовый план (q1–q4) + 'q1.base_plan','q2.base_plan','q3.base_plan','q4.base_plan', + -- Скоррект план (q2–q4) + '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). diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index 6ea8ade..615b017 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -1,6 +1,9 @@ +import datetime + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from src.services.org_unit_service import OrgUnitService from src.db.models.app_user import AppUser from src.domain.schemas import ( BaseListResponse, @@ -20,6 +23,18 @@ from src.db.session import get_db router = APIRouter(prefix="/stages", tags=["stages"]) +def timezone_from_offset(offset: str) -> datetime.timezone | None: + if not offset: + return None + + hours = int(offset[1:]) + return datetime.timezone( + datetime.timedelta( + hours=hours if offset[0] == '+' else -hours + ) + ) + + @router.get("/form/{form_id}") async def get_form_phases( form_id: int, @@ -29,14 +44,32 @@ async def get_form_phases( current_user: AppUser = Depends(get_current_active_user_with_set_db), ) -> BaseListResponse[FormPhaseResponse]: fp_service = FormPhaseService(db) + org_unit_service = OrgUnitService(db) + bf_service = BudgetFormService(db) + phases = await fp_service.get_list( budget_form_id=form_id, user=current_user, sheet=sheet, phase_code=phase_code, ) + if phases: + form = await bf_service.get(user=current_user, budget_form_id=form_id) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id) + result = [FormPhaseResponse.model_validate(p) for p in phases] + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + for element in result: + if element.opens_at: + element.opens_at = element.opens_at.astimezone(tz) + if element.closes_at: + element.closes_at = element.closes_at.astimezone(tz) + else: + result = [] + for element in result: + pass return BaseListResponse( - result=[FormPhaseResponse.model_validate(p) for p in phases], + result=result, count=len(phases), ) @@ -52,6 +85,9 @@ async def create_form_phase( form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True) if not form: raise HTTPException(404, "Форма не найдена") + + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id) fp_service = FormPhaseService(db) phase = await fp_service.create( @@ -59,7 +95,16 @@ async def create_form_phase( body=body, user=current_user, ) - return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase)) + + result = FormPhaseResponse.model_validate(phase) + + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + if result.opens_at: + result.opens_at = result.opens_at.astimezone(tz) + if result.closes_at: + result.closes_at = result.closes_at.astimezone(tz) + return BaseSingleResponse(result=result) @router.patch("/form/{form_id}/{sheet}/{phase_code}") @@ -84,9 +129,21 @@ async def update_form_phase( body=body, user=current_user, ) + if not phase: raise HTTPException(404, "Этап не найден") - return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase)) + + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id) + result = FormPhaseResponse.model_validate(phase) + + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + if result.opens_at: + result.opens_at = result.opens_at.astimezone(tz) + if result.closes_at: + result.closes_at = result.closes_at.astimezone(tz) + return BaseSingleResponse(result=result) @router.delete("/form/{form_id}/{sheet}/{phase_code}") diff --git a/api/src/api/v1/org_unit.py b/api/src/api/v1/org_unit.py index 661a5bc..9b55700 100644 --- a/api/src/api/v1/org_unit.py +++ b/api/src/api/v1/org_unit.py @@ -75,6 +75,7 @@ async def create_ssp( org_unit = await org_unit_service.create( title=org_data.title, is_ssp=org_data.is_ssp, + utc_offset=org_data.utc_offset, user=current_user, ) return BaseSingleResponse( diff --git a/api/src/db/models/org_unit.py b/api/src/db/models/org_unit.py index 2dbe4cd..10eb5cb 100644 --- a/api/src/db/models/org_unit.py +++ b/api/src/db/models/org_unit.py @@ -21,6 +21,7 @@ class OrgUnit(Base): title: Mapped[str] = mapped_column(String) is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_ssp: Mapped[bool] = mapped_column(Boolean) + utc_offset: Mapped[str] = mapped_column(String(3), default="+03") @property def users_count(self) -> int | None: diff --git a/api/src/db/models/phase_template.py b/api/src/db/models/phase_template.py index d69d7aa..d221fbf 100644 --- a/api/src/db/models/phase_template.py +++ b/api/src/db/models/phase_template.py @@ -22,8 +22,8 @@ class PhaseTemplate(Base): phase_code: Mapped[str] = mapped_column(String, primary_key=True) role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) - opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) + closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) # form_type_rel = relationship("FormType", foreign_keys=[form_type]) # role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates") diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 47ae594..c473f9f 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -235,12 +235,14 @@ class OrgUnitBaseSchema(BaseModel): class OrgUnitCreateSchema(OrgUnitBaseSchema): title: str = Field(..., min_length=1) + utc_offset: str = Field('+03', min_length=3, pattern=r'^[+]\d\d$') class OrgUnitUpdateSchema(OrgUnitBaseSchema): title: Optional[str] = None is_ssp: Optional[bool] = None is_active: Optional[bool] = None + utc_offset: Optional[str] = Field('+03', min_length=3, pattern=r'^[+]\d\d$') model_config = ConfigDict(from_attributes=True) @@ -248,6 +250,7 @@ class OrgUnitUpdateSchema(OrgUnitBaseSchema): class OrgUnitResponseSchema(OrgUnitBaseSchema): id: int is_active: bool + utc_offset: str users_count: int | None = None model_config = ConfigDict(from_attributes=True) @@ -265,6 +268,7 @@ class OrgUnitResponseSchema(OrgUnitBaseSchema): "is_ssp": data.is_ssp, "is_ssp": data.is_ssp, "is_active": data.is_active, + "utc_offset": data.utc_offset } try: result["users_count"] = data.users_count diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py index 046b13c..b572de6 100644 --- a/api/src/repository/form_phase_repository.py +++ b/api/src/repository/form_phase_repository.py @@ -55,8 +55,8 @@ class FormPhaseRepository: :phase_code, :role, :column_keys, - :opens_at, - :closes_at + CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), + CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) )).budget_form_id """ ), @@ -87,8 +87,8 @@ class FormPhaseRepository: :phase_code, :role, :column_keys, - :opens_at, - :closes_at + CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), + CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) )).budget_form_id """ ), diff --git a/api/src/repository/org_unit_repository.py b/api/src/repository/org_unit_repository.py index fccc9b9..d3a5c81 100644 --- a/api/src/repository/org_unit_repository.py +++ b/api/src/repository/org_unit_repository.py @@ -1,6 +1,6 @@ -from sqlalchemy import func, select, update +from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.org_unit import OrgUnit @@ -84,9 +84,10 @@ class OrgUnitRepository: self, title: str, is_ssp: bool, + utc_offset: str, ) -> OrgUnit: """Создание SSP.""" - ssp = OrgUnit(title=title, is_ssp=is_ssp) + ssp = OrgUnit(title=title, is_ssp=is_ssp, utc_offset=utc_offset) self.db.add(ssp) await self.db.flush() # await self.db.refresh(ssp) @@ -98,10 +99,29 @@ class OrgUnitRepository: if not org_unit: return None + old_offset = org_unit.utc_offset for field, value in kwargs.items(): setattr(org_unit, field, value) await self.db.flush() + + if org_unit.utc_offset != old_offset: + await self.db.execute( + text( + """ + SELECT v3.upd_phases_timezones( + :org_unit_id, + :old_tz, + :new_tz + ) + """ + ), + { + "org_unit_id": org_unit.id, + "old_tz": old_offset, + "new_tz": org_unit.utc_offset, + }, + ) # await self.db.refresh(ssp) return org_unit diff --git a/api/src/services/org_unit_service.py b/api/src/services/org_unit_service.py index e25a3a6..1313192 100644 --- a/api/src/services/org_unit_service.py +++ b/api/src/services/org_unit_service.py @@ -54,6 +54,7 @@ class OrgUnitService: self, title: str, is_ssp: bool, + utc_offset: str, user: AppUser, ) -> OrgUnit: """Создание нового ССП/РФ.""" @@ -63,6 +64,7 @@ class OrgUnitService: return await self.org_repo.create( title=title, is_ssp=is_ssp, + utc_offset=utc_offset, ) async def update( diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 92426ab..3bd120d 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -84,16 +84,16 @@ def test_form_phases_delete_forbidden(client, isp_tokens): def test_form_phases_create(client, admin_tokens): body = { - "budget_form_id": 1, + "budget_form_id": 4, "sheet": "AHR", "phase_code": "new_phase", "role": "DFIP", "column_keys": ["plan.q1"], - "opens_at": "2026-01-01T00:00:00Z", - "closes_at": "2026-12-31T00:00:00Z", + "opens_at": "2026-01-01T00:00:00", + "closes_at": "2026-12-31T00:00:00", } response = client.post( - "/api/v1/stages/form/1", + "/api/v1/stages/form/4", json=body, headers=_auth_headers(admin_tokens), ) @@ -102,22 +102,31 @@ def test_form_phases_create(client, admin_tokens): assert "success" in payload assert payload["success"] assert "result" in payload - assert isinstance(payload["result"], dict) + result = payload["result"] + assert isinstance(result, dict) + opens_at = result.pop("opens_at", None) + assert "+05" in opens_at + assert body.pop("opens_at") == opens_at.split("+")[0] + closes_at = result.pop("closes_at", None) + assert "+05" in closes_at + assert body.pop("closes_at") == closes_at.split("+")[0] + assert result == body + assert payload["result"] == body def test_form_phases_update(client, admin_tokens): body = { - "budget_form_id": 1, + "budget_form_id": 4, "sheet": "AHR", "phase_code": "test", "role": "EXECUTOR_RF", "column_keys": ["plan.q1"], - "opens_at": "2026-01-01T00:00:00Z", - "closes_at": "2026-12-31T00:00:00Z", + "opens_at": "2026-01-01T00:00:00", + "closes_at": "2026-12-31T00:00:00", } response = client.patch( - "/api/v1/stages/form/1/AHR/test", + "/api/v1/stages/form/4/AHR/test", json=body, headers=_auth_headers(admin_tokens), ) @@ -127,8 +136,15 @@ def test_form_phases_update(client, admin_tokens): assert "success" in payload assert payload["success"] assert "result" in payload - assert isinstance(payload["result"], dict) - assert payload["result"] == body + result = payload["result"] + assert isinstance(result, dict) + opens_at = result.pop("opens_at", None) + assert "+05" in opens_at + assert body.pop("opens_at") == opens_at.split("+")[0] + closes_at = result.pop("closes_at", None) + assert "+05" in closes_at + assert body.pop("closes_at") == closes_at.split("+")[0] + assert result == body def test_form_phases_delete(client, admin_tokens): diff --git a/api/tests/integration/test_org_units_integration.py b/api/tests/integration/test_org_units_integration.py index 6e44996..41e7720 100644 --- a/api/tests/integration/test_org_units_integration.py +++ b/api/tests/integration/test_org_units_integration.py @@ -78,6 +78,47 @@ def test_org_units_get_not_found(client, admin_tokens, auth_headers): # assert payload["result"]["id"] == 1 +def test_org_units_update_offset_smoke(client, admin_tokens, auth_headers): + old_offset = "+05" + new_offset = "+04" + response = client.get( + "/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + phases = payload["result"] + assert isinstance(phases, list) + assert len(phases) + phase_old = phases[0] + + response = client.patch( + "/api/v1/org-unit/2", + json={"utc_offset": new_offset}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["result"]["id"] == 2 + assert payload["result"]["utc_offset"] == new_offset + + response = client.get( + "/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + phases = payload["result"] + assert isinstance(phases, list) + assert len(phases) + phase_new = phases[0] + + assert new_offset in phase_new["opens_at"] + assert new_offset in phase_new["closes_at"] + assert phase_old["opens_at"].split(old_offset)[0] == phase_new["opens_at"].split(new_offset)[0] + assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0] + + def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers): response = client.patch( "/api/v1/org-unit/99999", diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 8e6ac01..9e4447e 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -3,9 +3,9 @@ INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_ (2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'); SELECT setval('v3.app_user_id_seq', 2); -INSERT INTO v3.org_unit (id,title,is_active,is_ssp) VALUES - (1,'Test_SSP',true,true), - (2,'Test РФ',true,false); +INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES + (1,'Test_SSP',true,true,'+03'), + (2,'Test РФ',true,false,'+05'); SELECT setval('v3.org_unit_id_seq', 2); INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES @@ -24,7 +24,8 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or SELECT setval('v3.budget_line_id_seq', 1); INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES - (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'); + (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'), + (4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05'); INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES (1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','',NULL),