From 57f919ac0c0a5d26d166c5c2f10412ff591686c5 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Mon, 3 Aug 2026 18:43:06 +0300 Subject: [PATCH] =?UTF-8?q?phase-directions:=20=D0=BD=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D1=85,=20=D0=B3=D0=BE=D0=B4=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=20=D0=B3=D0=BE=D0=B4=D0=B0=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=D0=B8=D0=BB=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0,=20=D1=84=D0=B8=D0=BA=D1=81=20=D1=81?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0014_direction.py | 82 ++ api/alembic/versions/sql/0014_direction.sql | 1281 +++++++++++++++++ api/src/api/v1/form_phases.py | 6 + api/src/db/models/form_phase.py | 1 + api/src/db/models/phase_template.py | 1 + api/src/domain/schemas.py | 1 + api/src/repository/form_phase_repository.py | 34 +- api/src/repository/sheet_repository.py | 1 - api/src/services/form_phase_service.py | 29 +- .../integration/test_form_phases_api_smoke.py | 2 +- api/tests/sqls/fixture.sql | 7 +- 11 files changed, 1427 insertions(+), 18 deletions(-) create mode 100644 api/alembic/versions/0014_direction.py create mode 100644 api/alembic/versions/sql/0014_direction.sql diff --git a/api/alembic/versions/0014_direction.py b/api/alembic/versions/0014_direction.py new file mode 100644 index 0000000..c090432 --- /dev/null +++ b/api/alembic/versions/0014_direction.py @@ -0,0 +1,82 @@ +import os +import re + +from alembic import op + + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _find_dollar_tag(line: str) -> str | None: + 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: str | None = None + + for line in sql.split("\n"): + stripped = line.strip() + if stripped.startswith("--"): + continue + + 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", "0014_direction.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/sql/0014_direction.sql b/api/alembic/versions/sql/0014_direction.sql new file mode 100644 index 0000000..939ec66 --- /dev/null +++ b/api/alembic/versions/sql/0014_direction.sql @@ -0,0 +1,1281 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- МИГРАЦИЯ: Добавление поля direction в phase_template и form_phase +-- ════════════════════════════════════════════════════════════════════════════ + + +-- Добавляем direction +ALTER TABLE v3.phase_template ADD COLUMN direction VARCHAR; +ALTER TABLE v3.form_phase ADD COLUMN direction VARCHAR; + +ALTER TABLE v3.form_phase DROP CONSTRAINT IF EXISTS form_phase_pkey; +CREATE UNIQUE INDEX form_phase_uidx ON v3.form_phase(budget_form_id, sheet, direction, phase_code); + +ALTER TABLE v3.phase_template DROP CONSTRAINT IF EXISTS phase_template_pkey; +CREATE UNIQUE INDEX phase_template_uidx ON v3.phase_template(form_type, sheet, direction, phase_code); + + +-- Добаввляем direction во все нужные функции фаз и шаблонов фаз +DROP FUNCTION IF EXISTS v3.add_form_phase(int4, varchar, varchar, varchar, _text, timestamp without time zone, timestamp without time zone); + +CREATE OR REPLACE FUNCTION v3.add_form_phase( + p_budget_form_id integer, + p_sheet character varying, + p_direction character varying, + p_phase_code character varying, + p_role character varying, + p_column_keys text[], + p_opens_at timestamp without time zone, + p_closes_at timestamp without time zone +) + RETURNS v3.form_phase + LANGUAGE plpgsql +AS $function$ +DECLARE + v_phase v3.form_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); +BEGIN + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit_id; + + INSERT INTO v3.form_phase( + budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at + ) + VALUES ( + p_budget_form_id, + p_sheet, + p_direction, + p_phase_code, + p_role, + p_column_keys, + CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END, + CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END + + ) + RETURNING * INTO v_phase; + + + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'task', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'create', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'direction', p_direction, + 'phase_code', p_phase_code, + 'role', v_phase.role, + 'column_keys', v_phase.column_keys, + 'opens_at', v_phase.opens_at, + 'closes_at', v_phase.closes_at + ), + p_budget_form_id, null, v_org_unit_id + ); + + RETURN v_phase; +END; +$function$ +; + + +DROP FUNCTION IF EXISTS v3.can_edit(int4, varchar, text, int4); + + +CREATE OR REPLACE FUNCTION v3.can_edit(p_form_id integer, p_sheet character varying, p_direction character varying, p_column_key text, p_user_id integer) + RETURNS jsonb + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_role VARCHAR; + v_active_close TIMESTAMPTZ; + v_role_has_col BOOLEAN; + v_next_open TIMESTAMPTZ; + v_last_close TIMESTAMPTZ; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', 'unknown or inactive user' + ); + END IF; + + -- ADMIN — bypass всех проверок. + IF v_role = 'ADMIN' THEN + RETURN jsonb_build_object('ok', true, 'admin', true); + END IF; + + -- EXECUTOR_RF: фильтр по user_org формы. + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_form_org(p_user_id, p_form_id) THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'org_not_assigned', + 'detail', p_column_key + ); + END IF; + + -- 1. Активная сейчас фаза, в которой эта колонка перечислена для этой роли? + SELECT MAX(fp.closes_at) + INTO v_active_close + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND now() BETWEEN fp.opens_at AND fp.closes_at; + + IF v_active_close IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', true, + 'closes_at', v_active_close + ); + END IF; + + -- 2. Колонка В ПРИНЦИПЕ есть хоть в одной фазе этой роли (на этой форме/листе)? + -- Если нет — это вопрос whitelist, отдаём role_not_allowed. + SELECT EXISTS ( + SELECT 1 + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + ) INTO v_role_has_col; + + IF NOT v_role_has_col THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', p_column_key + ); + END IF; + + -- 3. Колонка доступна, но окно не активно. Подсказать ближайшее. + SELECT MIN(fp.opens_at) + INTO v_next_open + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.opens_at > now(); + + IF v_next_open IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'opens_at', v_next_open + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_last_close + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.closes_at <= now(); + + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'closes_at', v_last_close + ); +END; +$function$ +; + + +CREATE OR REPLACE FUNCTION v3.upd_form_cells(p_form_id integer, p_sheet character varying, p_changes jsonb, p_user_id integer, p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[]) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_change JSONB; + v_column TEXT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'role_not_allowed: '; + END IF; + + IF NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id AND is_active) THEN + RAISE EXCEPTION 'role_not_allowed: unknown or inactive user %', p_user_id; + END IF; + + IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN + RAISE EXCEPTION 'p_changes must be a JSONB array'; + END IF; + + -- Проверяем КАЖДУЮ ячейку до начала записи. Падаем на первой запрещённой — + -- транзакция целиком откатится, частичной записи не будет. + FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes) + LOOP + v_column := v_change->>'column'; + IF v_column IS NULL THEN + RAISE EXCEPTION 'change must have column: %', v_change; + END IF; + + v_decision := v3.can_edit(p_form_id, p_sheet, p_direction, v_column, p_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: %', v_column; + + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: %', v_column; + + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: %s (opens %s)', + v_column, v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: %s (closed %s)', + v_column, v_decision->>'closes_at'); + ELSE + v_msg := format('window_closed: %s (no phase)', v_column); + END IF; + RAISE EXCEPTION '%', v_msg; + + ELSE + RAISE EXCEPTION '%: %', v_decision->>'code', v_column; + END IF; + END IF; + END LOOP; + + -- Все ячейки разрешены — делегируем в v3. + -- ROW_UPDATE намеренно НЕ логируется (см. logging_events.md): cell-edit'ы + -- слишком частые. В audit_log пишем только структурные события + -- (ROW_CREATE / ROW_DELETE) + бизнес-операции (CREATE_TASK и т.д.). + RETURN QUERY + SELECT * FROM v3.upd_form_cells( + p_form_id, p_sheet, p_changes, p_direction, p_sections + ); +END; +$function$ +; + + +CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer) + RETURNS integer + LANGUAGE plpgsql +AS $function$ + DECLARE + v_form_type VARCHAR; + v_inserted INTEGER; + v_utc_offset VARCHAR(3); + v_org_unit INTEGER; + v_year INTEGER; + BEGIN + SELECT form_type_code, org_unit_id, "year" INTO v_form_type, v_org_unit, v_year + FROM v3.budget_form + WHERE id = p_form_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit; + + INSERT INTO v3.form_phase + (budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at) + SELECT p_form_id, pt.sheet, pt.direction, pt.phase_code, pt.role, + pt.column_keys, + ((pt.opens_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz, + ((pt.closes_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz + FROM v3.phase_template pt + WHERE pt.form_type = v_form_type + ON CONFLICT (budget_form_id, sheet, phase_code, direction) DO NOTHING; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; + END; + $function$ +; + + +-- DROP FUNCTION v3.copy_template_to_report(int4); + +CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer) + RETURNS integer + LANGUAGE plpgsql +AS $function$ +DECLARE + v_report_type VARCHAR; + v_inserted INTEGER; + v_org_unit INTEGER; + v_utc_offset VARCHAR(3); + v_year INTEGER; +BEGIN + SELECT report_type, org_unit_id, "year" INTO v_report_type, v_org_unit, v_year + FROM v3.rf_project_report AS rpr + INNER JOIN v3.project AS p ON p.id = rpr.project_id + WHERE rpr.id = p_report_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit; + + INSERT INTO v3.form3_phase + (rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at) + SELECT p_report_id, pt.phase_code, pt.role, + pt.column_keys, + ((pt.opens_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz, + ((pt.closes_at + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz + FROM v3.phase_template pt + WHERE pt.form_type = 'FORM_3' + AND pt.sheet = v_report_type + ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; +END; +$function$ +; + +DROP FUNCTION IF EXISTS v3.del_form_phase(int4, varchar, varchar); + + +CREATE OR REPLACE FUNCTION v3.del_form_phase(p_budget_form_id integer, p_sheet character varying, p_direction character varying, p_phase_code character varying) + RETURNS boolean + LANGUAGE plpgsql +AS $function$ + DECLARE + v_old v3.form_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + BEGIN + DELETE FROM v3.form_phase + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL)) + RETURNING * INTO v_old; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'task', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'delete', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'direction', p_direction, + 'phase_code', p_phase_code, + 'role', v_old.role, + 'column_keys', v_old.column_keys, + 'opens_at', v_old.opens_at, + 'closes_at', v_old.closes_at + ), + p_budget_form_id, null, v_org_unit_id + ); + RETURN TRUE; + END; + $function$ +; + + + +DROP FUNCTION IF EXISTS v3.editable_columns_for(int4, int4, varchar); + +CREATE OR REPLACE FUNCTION v3.editable_columns_for(p_form_id integer, p_user_id integer, p_sheet character varying, p_direction character varying) + RETURNS TABLE(column_key text, closes_at timestamp with time zone) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_role VARCHAR; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN; -- неизвестный/неактивный юзер → пустая маска + END IF; + + -- ADMIN: вернуть UNION всех column_keys всех phase'ов на форме/листе. + IF v_role = 'ADMIN' THEN + RETURN QUERY + SELECT ck AS column_key, + MAX(fp.closes_at) AS closes_at + FROM v3.form_phase fp, + LATERAL unnest(fp.column_keys) AS ck + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + GROUP BY ck; + RETURN; + END IF; + + -- EXECUTOR_RF без назначения → пустая маска (default deny). + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_form_org(p_user_id, p_form_id) THEN + RETURN; + END IF; + + RETURN QUERY + SELECT ck AS column_key, + MAX(fp.closes_at) AS closes_at + FROM v3.form_phase fp, + LATERAL unnest(fp.column_keys) AS ck + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND now() BETWEEN fp.opens_at AND fp.closes_at + GROUP BY ck; +END; +$function$ +; + + +CREATE OR REPLACE FUNCTION v3.v_form_view(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[], p_direction character varying DEFAULT NULL::character varying, p_user_id integer DEFAULT NULL::integer) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_form_type VARCHAR; + v_year INT; + v_org_unit_id INT; +BEGIN + SELECT form_type_code, year, org_unit_id + INTO v_form_type, v_year, v_org_unit_id + FROM v3.budget_form + WHERE id = p_form_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'budget_form id=% не существует в v3.budget_form', p_form_id; + END IF; + + IF p_user_id IS NOT NULL THEN + RETURN QUERY + SELECT + 'META'::VARCHAR, + -1, + 0::BIGINT, + jsonb_build_object( + 'user_id', p_user_id, + 'role', v3.user_role_code(p_user_id), + 'sheet', p_sheet, + 'editable', COALESCE( + (SELECT jsonb_agg( + jsonb_build_object('column', ec.column_key, + 'closes_at', ec.closes_at) + ORDER BY ec.column_key) + FROM v3.editable_columns_for(p_form_id, p_user_id, p_sheet, p_direction) ec), + '[]'::JSONB) + ); + END IF; + + IF v_form_type = 'FORM_1' THEN + + IF p_sheet = 'SMETA' THEN + RETURN QUERY + SELECT + sm.row_type, + sm.depth, + ROW_NUMBER() OVER (ORDER BY sm.section_code) AS sort_order, + jsonb_build_object( + 'section_code', sm.section_code, + 'name', sm.name, + 'plan', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.supp_plan_q1, 'q2', sm.supp_plan_q2, + 'q3', sm.supp_plan_q3, 'q4', sm.supp_plan_q4, + 'year', sm.supp_plan_year), + 'development', jsonb_build_object('q1', sm.dev_plan_q1, 'q2', sm.dev_plan_q2, + 'q3', sm.dev_plan_q3, 'q4', sm.dev_plan_q4, + 'year', sm.dev_plan_year), + 'total_year', sm.total_plan_year), + 'approved', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.supp_appr_q1, 'q2', sm.supp_appr_q2, + 'q3', sm.supp_appr_q3, 'q4', sm.supp_appr_q4, + 'year', sm.supp_appr_year), + 'development', jsonb_build_object('q1', sm.dev_appr_q1, 'q2', sm.dev_appr_q2, + 'q3', sm.dev_appr_q3, 'q4', sm.dev_appr_q4, + 'year', sm.dev_appr_year), + 'total_year', sm.total_appr_year), + 'fact', jsonb_build_object( + 'support', jsonb_build_object('q1', sm.supp_act_q1, 'q2', sm.supp_act_q2, + 'q3', sm.supp_act_q3, 'q4', sm.supp_act_q4, + 'year', sm.supp_act_year), + 'development', jsonb_build_object('q1', sm.dev_act_q1, 'q2', sm.dev_act_q2, + 'q3', sm.dev_act_q3, 'q4', sm.dev_act_q4, + 'year', sm.dev_act_year), + 'total_year', sm.total_act_year), + 'corrected', jsonb_build_object( + 'support', jsonb_build_object('q2', sm.supp_corr_q2, 'q3', sm.supp_corr_q3, 'q4', sm.supp_corr_q4), + 'development', jsonb_build_object('q2', sm.dev_corr_q2, 'q3', sm.dev_corr_q3, 'q4', sm.dev_corr_q4)) + ) + FROM v3.v_form1_smeta(v_year, v_org_unit_id) sm + ORDER BY sm.section_code; + RETURN; + END IF; + + IF p_sheet IN ('AHR','CAP','OPER') THEN + RETURN QUERY + SELECT + j.row_type, + j.depth, + ROW_NUMBER() OVER (ORDER BY j._sort_path) AS sort_order, + jsonb_build_object( + 'line_id', j.line_id, + 'header', j.header, + 'plan', j.plan_data, + 'contract_summary', j.contract_summary, + 'allocation', j.allocation_data, + 'sequestration', j.sequestration_data, + 'reserve', j.reserve_data, + 'approved', j.approved_data, + 'collegial', j.collegial_data, + 'ckk', j.ckk_data, + 'contract_detail', j.contract_detail, + 'q1', j.q1_data, + 'q2', j.q2_data, + 'q3', j.q3_data, + 'q4', j.q4_data, + 'totals', j.totals_data + ) + FROM v3.v_form1_sheet_jsonb(p_form_id, p_sheet, p_direction, p_sections) j + ORDER BY j._sort_path; + RETURN; + END IF; + + RAISE EXCEPTION 'Unknown FORM_1 sheet: %', p_sheet + USING HINT = 'Use AHR / CAP / OPER / SMETA'; + END IF; + + IF v_form_type = 'FORM_2' THEN + RETURN QUERY SELECT * FROM v3.v_form2_view(p_form_id, p_sheet, p_sections); + RETURN; + END IF; + + IF v_form_type = 'FORM_4' THEN + RETURN QUERY SELECT * FROM v3.v_form4_view(p_form_id, p_sheet, p_sections); + RETURN; + END IF; + + RAISE EXCEPTION 'Unsupported form_type=% for form_id=%', v_form_type, p_form_id; + +END; +$function$ +; + + + +DROP FUNCTION IF EXISTS v3.upd_form_phase(int4, varchar, varchar, varchar, _text, timestamp, timestamp); + +CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_direction character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone) + RETURNS v3.form_phase + LANGUAGE plpgsql +AS $function$ +DECLARE + v_old v3.form_phase; + v_new v3.form_phase; + v_is_extend BOOLEAN := FALSE; + v_has_non_extend_change BOOLEAN := FALSE; + v_has_window_change BOOLEAN := FALSE; + v_changes JSONB := '{}'::jsonb; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_opens_at timestamptz; + v_closes_at timestamptz; +BEGIN + SELECT * INTO v_old + FROM v3.form_phase + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL)) + AND phase_code = p_phase_code; + + IF NOT FOUND THEN + RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)', + p_budget_form_id, p_sheet, p_phase_code; + END IF; + + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + + IF p_opens_at is not null THEN + v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz; + END IF; + IF p_closes_at is not null THEN + v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz; + END IF; + + UPDATE v3.form_phase + SET role = COALESCE(p_role, role), + column_keys = COALESCE(p_column_keys, column_keys), + opens_at = COALESCE(v_opens_at, opens_at), + closes_at = COALESCE(v_closes_at, closes_at) + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND (direction = p_direction OR (direction IS NULL AND p_direction IS NULL)) + AND phase_code = p_phase_code + RETURNING * INTO v_new; + + + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + v_is_extend := v_new.closes_at > v_old.closes_at; + v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role + OR v_old.column_keys IS DISTINCT FROM v_new.column_keys + OR v_old.opens_at IS DISTINCT FROM v_new.opens_at; + v_has_window_change := v_has_non_extend_change + OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend); + + IF v_old.role IS DISTINCT FROM v_new.role THEN + v_changes := v_changes || jsonb_build_object( + 'role', jsonb_build_object('before', v_old.role, 'after', v_new.role) + ); + END IF; + IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN + v_changes := v_changes || jsonb_build_object( + 'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys) + ); + END IF; + IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN + v_changes := v_changes || jsonb_build_object( + 'opens_at', jsonb_build_object( + 'before', v_old.opens_at, + 'after', v_new.opens_at + ) + ); + END IF; + IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN + v_changes := v_changes || jsonb_build_object( + 'closes_at', jsonb_build_object( + 'before', v_old.closes_at, + 'after', v_new.closes_at + ) + ); + END IF; + + IF v_is_extend THEN + PERFORM v3.log_event( + 'ACCESS_EXTEND', 'ACCESS', + jsonb_build_object( + 'entity_type', 'task', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'closes_at_before', v_old.closes_at, + 'closes_at_after', v_new.closes_at + ), + p_budget_form_id + ); + END IF; + + IF v_has_window_change THEN + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'task', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'changes', v_changes + ), + p_budget_form_id, null, v_org_unit_id + ); + END IF; + + RETURN v_new; +END; +$function$ +; + + +-- Обновляем direction для фаз первой формы + +UPDATE v3.phase_template pt +SET direction = 'Support' +WHERE sheet IN ('AHR', 'CAP') + AND form_type = 'FORM_1'; + +INSERT INTO v3.phase_template + (form_type, sheet, direction, phase_code, role, column_keys, opens_at, closes_at) + SELECT pt.form_type, pt.sheet, 'Development', pt.phase_code, pt.role, + pt.column_keys, pt.opens_at, + pt.closes_at + FROM v3.phase_template pt + WHERE pt.sheet IN ('AHR', 'CAP') + AND pt.form_type = 'FORM_1'; + + + +UPDATE v3.form_phase fp +SET direction = 'Support' +WHERE sheet IN ('AHR', 'CAP') + AND budget_form_id IN ( + SELECT id FROM v3.budget_form WHERE form_type_code = 'FORM_1' + ); + +INSERT INTO v3.form_phase + (budget_form_id, sheet, direction, phase_code, role, column_keys, opens_at, closes_at) + SELECT fp.budget_form_id, fp.sheet, 'Development', fp.phase_code, fp.role, + fp.column_keys, fp.opens_at, fp.closes_at + FROM v3.form_phase fp + WHERE fp.sheet IN ('AHR', 'CAP') + AND budget_form_id IN ( + SELECT id FROM v3.budget_form WHERE form_type_code = 'FORM_1' + ); + + + +-- Добавляем возможность менять justification у budget_line + +CREATE OR REPLACE FUNCTION v3._apply_form_cell(p_line_id integer, p_column text, p_value jsonb, p_sheet character varying DEFAULT NULL::character varying, p_form_id integer DEFAULT NULL::integer) + RETURNS void + LANGUAGE plpgsql +AS $function$ +DECLARE + v_parts TEXT[]; + v_scope TEXT; + v_field TEXT; + v_q SMALLINT; + v_actor TEXT; + v_target_table TEXT; + v_target_col TEXT; + v_target_type TEXT; + v_key_kind TEXT; + v_str TEXT; + v_sql TEXT; + v_sat_table TEXT; + v_cnt INT; +BEGIN + v_parts := string_to_array(p_column, '.'); + + IF p_sheet = 'OTCH9F' THEN + DECLARE v_far_month SMALLINT; + BEGIN + SELECT month INTO v_far_month FROM v3.fixed_asset_report WHERE id = p_line_id; + IF v_far_month IS NULL THEN + RAISE EXCEPTION 'fixed_asset_report.id=% не существует', p_line_id; + END IF; + + IF array_length(v_parts,1) = 1 THEN + IF v_parts[1] = 'equipment_name' THEN + RAISE EXCEPTION 'normative_field: equipment_name (нормативная номенклатура, правится отдельным API)'; + ELSIF v_parts[1] IN ('month','expense_item_id','item_id','section_code','id') THEN + RAISE EXCEPTION 'key_field: % (часть ключа строки, RO)', v_parts[1]; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + END IF; + + IF array_length(v_parts,1) <> 2 THEN + RAISE EXCEPTION 'bad_column_format: %', p_column; + END IF; + + IF v_parts[1] = 'totals' THEN + IF v_parts[2] = 'go_balance_only' THEN + v_target_col := 'go_balance_only_amt'; v_target_type := 'NUMERIC'; + ELSIF v_parts[2] IN ('acquired_total','disposed_total','balance_qty','balance_amt') THEN + RAISE EXCEPTION 'computed_field: totals.%', v_parts[2]; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + ELSIF v_parts[1] IN ('b_604','b_60415') THEN + IF v_parts[2] LIKE 'opening_%' AND v_far_month <> 1 THEN + RAISE EXCEPTION 'computed_field: % (opening для month>1 вычисляется из base + накопит. дельты)', p_column; + END IF; + IF v_parts[1] = 'b_604' THEN + v_target_col := CASE v_parts[2] + WHEN 'opening_qty' THEN 'opening_qty_604' + WHEN 'opening_amt' THEN 'opening_amt_604' + WHEN 'acquired_qty' THEN 'acquired_qty_604' + WHEN 'acquired_amt' THEN 'acquired_amt_604' + WHEN 'disposed_qty' THEN 'disposed_qty_604' + WHEN 'disposed_amt' THEN 'disposed_amt_604' + END; + IF v_target_col IS NULL THEN + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END; + ELSE -- b_60415 + v_target_col := CASE v_parts[2] + WHEN 'opening_qty' THEN 'opening_qty_60415' + WHEN 'opening_amt' THEN 'opening_amt_60415' + WHEN 'acquired_qty' THEN 'acquired_qty_60415' + WHEN 'acquired_amt' THEN 'acquired_amt_60415' + WHEN 'transferred_qty' THEN 'transferred_qty_60415' + WHEN 'transferred_amt' THEN 'transferred_amt_60415' + END; + IF v_target_col IS NULL THEN + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + v_target_type := CASE WHEN v_parts[2] LIKE '%_qty' THEN 'INTEGER' ELSE 'NUMERIC' END; + END IF; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + EXECUTE format( + 'UPDATE v3.fixed_asset_report SET %I = ($2 #>> ''{}'')::%s WHERE id = $1', + v_target_col, v_target_type + ) USING p_line_id, p_value; + RETURN; + END; + END IF; + + IF p_sheet = 'AHR_LIMIT' THEN + IF p_form_id IS NULL THEN + RAISE EXCEPTION 'internal: p_form_id не передан для AHR_LIMIT'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM v3.limit_template lt + WHERE lt.id = p_line_id AND lt.row_type = 'LEAF') THEN + RAISE EXCEPTION 'limit_template.id=% не существует или не LEAF (SECTION/GROUP не редактируется)', p_line_id; + END IF; + + IF array_length(v_parts,1) <> 1 THEN + RAISE EXCEPTION 'bad_column_format: %, expected single key', p_column; + END IF; + + CASE v_parts[1] + WHEN 'qty_q1','qty_q2','qty_q3','qty_q4' THEN + v_target_col := v_parts[1]; v_target_type := 'INTEGER'; + WHEN 'comment' THEN + v_target_col := 'comment'; v_target_type := 'TEXT'; + WHEN 'amount_q1','amount_q2','amount_q3','amount_q4' THEN + RAISE EXCEPTION 'computed_field: % (вычисляется из qty × limit × period_factor)', p_column; + WHEN 'name','unit','section_no','expense_item_code', + 'limit_with_vat','limit_without_vat' THEN + RAISE EXCEPTION 'normative_field: % (нормативный справочник, правится админом отдельно)', p_column; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END CASE; + + EXECUTE format( + 'INSERT INTO v3.form_limit (budget_form_id, template_id, %1$I) ' + || 'VALUES ($1, $2, ($3 #>> ''{}'')::%2$s) ' + || 'ON CONFLICT (budget_form_id, template_id) DO UPDATE SET %1$I = EXCLUDED.%1$I', + v_target_col, v_target_type + ) USING p_form_id, p_line_id, p_value; + RETURN; + END IF; + + IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN + v_sat_table := CASE p_sheet + WHEN 'AHR_RENT' THEN 'rent_detail' + WHEN 'AHR_UTILITY' THEN 'utility_detail' + WHEN 'AHR_SECURITY' THEN 'security_detail' + END; + + IF array_length(v_parts,1) = 1 THEN + CASE v_parts[1] + WHEN 'contract_number' THEN v_target_col := 'contract_number'; v_target_type := 'TEXT'; + WHEN 'contract_end_date' THEN v_target_col := 'contract_end_date'; v_target_type := 'DATE'; + WHEN 'comment' THEN v_target_col := 'comment'; v_target_type := 'TEXT'; + WHEN 'address','object_type','rented_area','object_area' THEN + RAISE EXCEPTION 'computed_field: % (атрибут v3.vsp, правится отдельно)', p_column; + ELSE RAISE EXCEPTION 'unknown_column: %', p_column; + END CASE; + ELSIF array_length(v_parts,1) = 2 AND v_parts[1] = 'plan' THEN + IF v_parts[2] = 'year' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF; + IF v_parts[2] NOT IN ('q1','q2','q3','q4') THEN RAISE EXCEPTION 'unknown_column: %', p_column; END IF; + v_target_col := 'plan_' || v_parts[2]; + v_target_type := 'NUMERIC'; + ELSIF array_length(v_parts,1) = 2 AND v_parts[1] LIKE 'fact_q%' THEN + IF v_parts[2] = 'total' THEN RAISE EXCEPTION 'computed_field: %', p_column; END IF; + IF (v_parts[1]='fact_q1' AND v_parts[2] NOT IN ('jan','feb','mar')) OR + (v_parts[1]='fact_q2' AND v_parts[2] NOT IN ('apr','may','jun')) OR + (v_parts[1]='fact_q3' AND v_parts[2] NOT IN ('jul','aug','sep')) OR + (v_parts[1]='fact_q4' AND v_parts[2] NOT IN ('oct','nov','dec')) THEN + RAISE EXCEPTION 'unknown_column: % (месяц вне квартала)', p_column; + END IF; + v_target_col := 'actual_' || v_parts[2]; + v_target_type := 'NUMERIC'; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + EXECUTE format( + 'UPDATE v3.%I SET %I = ($2 #>> ''{}'')::%s WHERE id = $1', + v_sat_table, v_target_col, v_target_type + ) USING p_line_id, p_value; + + GET DIAGNOSTICS v_cnt = ROW_COUNT; + IF v_cnt = 0 THEN + RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_line_id; + END IF; + RETURN; + END IF; + + IF array_length(v_parts, 1) < 2 THEN + RAISE EXCEPTION 'bad_column_format: %, expected scope.field', p_column; + END IF; + v_scope := v_parts[1]; + v_field := v_parts[2]; + + IF v_scope = 'header' THEN + IF v_field = 'name' THEN + UPDATE v3.budget_line SET name = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'internal_order' THEN + UPDATE v3.budget_line SET internal_order = (p_value #>> '{}'), updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'justification' THEN + UPDATE v3.budget_line SET justification = (p_value #>> '{}')::TEXT, updated_at = now() WHERE id = p_line_id; + ELSIF v_field = 'vsp_id' THEN + UPDATE v3.budget_line SET vsp_id = (p_value #>> '{}')::INT, updated_at = now() WHERE id = p_line_id; + ELSIF v_field IN ('year','section','item_id','num_group','vsp_address') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + ELSE + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + RETURN; + END IF; + + IF v_scope IN ('approved','totals') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + END IF; + + IF v_scope = 'booking' THEN + IF array_length(v_parts, 1) <> 3 THEN + RAISE EXCEPTION 'bad_column_format: %, expected booking.y{2026|2027}.qN', p_column; + END IF; + IF v_parts[2] NOT IN ('y2026','y2027') THEN + RAISE EXCEPTION 'bad_booking_year: % (only y2026/y2027)', v_parts[2]; + END IF; + IF v_parts[3] NOT IN ('q1','q2','q3','q4') THEN + RAISE EXCEPTION 'bad_booking_quarter: %', v_parts[3]; + END IF; + v_target_col := CASE v_parts[2] + WHEN 'y2026' THEN 'expenses_' || v_parts[3] + WHEN 'y2027' THEN 'expenses_next_year_' || v_parts[3] + END; + v_sql := format( + 'INSERT INTO v3.ckk (line_id, %1$I) VALUES ($1, ($2 #>> ''{}'')::NUMERIC) ' + || 'ON CONFLICT (line_id) DO UPDATE SET %1$I = EXCLUDED.%1$I', + v_target_col + ); + EXECUTE v_sql USING p_line_id, p_value; + UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id; + RETURN; + END IF; + + + IF v_scope IN ('q1','q2','q3','q4') THEN + v_q := substring(v_scope FROM 2)::SMALLINT; + ELSE + v_q := NULL; + END IF; + + v_actor := CASE v_scope + WHEN 'sequestration' THEN 'DFIP' + WHEN 'seq_dfip' THEN 'DFIP' + WHEN 'seq_ssp' THEN 'SSP_GO' + ELSE NULL END; + + SELECT m.target_table, m.target_col, m.target_type, m.key_kind + INTO v_target_table, v_target_col, v_target_type, v_key_kind + FROM (VALUES + ('plan','q1', 'plan','plan_q1','NUMERIC','line'), + ('plan','q2', 'plan','plan_q2','NUMERIC','line'), + ('plan','q3', 'plan','plan_q3','NUMERIC','line'), + ('plan','q4', 'plan','plan_q4','NUMERIC','line'), + ('plan','comment', 'plan','comment','TEXT', 'line'), + + ('reserve','q1', 'reserve','amount_q1','NUMERIC','line'), + ('reserve','q2', 'reserve','amount_q2','NUMERIC','line'), + ('reserve','q3', 'reserve','amount_q3','NUMERIC','line'), + ('reserve','q4', 'reserve','amount_q4','NUMERIC','line'), + ('reserve','justification','reserve','justification','TEXT','line'), + + ('allocation','order', 'allocation','internal_order','TEXT','line'), + ('allocation','property', 'allocation','property_object','TEXT','line'), + ('allocation','contract_ref', 'allocation','contract_ref','TEXT','line'), + ('allocation','allocation_purpose', 'allocation','allocation_purpose','TEXT','line'), + + ('contract_summary','total', 'contract_summary','total_amount', 'NUMERIC','line'), + ('contract_summary','counterparty', 'contract_summary','counterparty', 'TEXT', 'line'), + ('contract_summary','deadline', 'contract_summary','deadline', 'TEXT', 'line'), + ('contract_summary','comment', 'contract_summary','comment', 'TEXT', 'line'), + ('contract_summary','future_y1', 'contract_summary','future_payments_y1', 'NUMERIC','line'), + ('contract_summary','future_y2', 'contract_summary','future_payments_y2', 'NUMERIC','line'), + ('contract_summary','other_ssp', 'contract_summary','other_ssp_amount', 'NUMERIC','line'), + ('contract_summary','reference', 'contract_summary','reference', 'TEXT','line'), + ('contract_summary','centralized_flag', 'contract_summary','centralized_flag', 'TEXT','line'), + + ('collegial','approved', 'collegial_approval','approved_amount', 'NUMERIC','line'), + ('collegial','protocol', 'collegial_approval','protocol_reference','TEXT', 'line'), + ('collegial','note', 'collegial_approval','note', 'TEXT', 'line'), + + ('ckk','ceiling', 'ckk','ceiling_amount', 'NUMERIC','line'), + ('ckk','q1', 'ckk','expenses_q1', 'NUMERIC','line'), + ('ckk','q2', 'ckk','expenses_q2', 'NUMERIC','line'), + ('ckk','q3', 'ckk','expenses_q3', 'NUMERIC','line'), + ('ckk','q4', 'ckk','expenses_q4', 'NUMERIC','line'), + ('ckk','next_q1', 'ckk','expenses_next_year_q1', 'NUMERIC','line'), + ('ckk','next_q2', 'ckk','expenses_next_year_q2', 'NUMERIC','line'), + ('ckk','next_q3', 'ckk','expenses_next_year_q3', 'NUMERIC','line'), + ('ckk','next_q4', 'ckk','expenses_next_year_q4', 'NUMERIC','line'), + ('ckk','rf_schedule', 'ckk','rf_schedule', 'TEXT', 'line'), + ('ckk','deadline', 'ckk','delivery_deadline', 'TEXT', 'line'), + ('ckk','proc_plan', 'ckk','procurement_plan', 'TEXT', 'line'), + ('ckk','proc_method', 'ckk','procurement_method','TEXT', 'line'), + ('ckk','comment', 'ckk','comment', 'TEXT', 'line'), + + ('contract_detail','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'), + ('contract_detail','reference', 'contract_detail','reference', 'TEXT', 'line'), + ('contract_detail','addenda', 'contract_detail','addenda', 'TEXT', 'line'), + ('contract_detail','subject', 'contract_detail','subject', 'TEXT', 'line'), + ('contract_detail','currency', 'contract_detail','currency', 'TEXT', 'line'), + ('contract_detail','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'), + ('contract_detail','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'), + ('contract_detail','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'), + ('contract_detail','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'), + ('contract_detail','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'), + ('contract_detail','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'), + ('contract_detail','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'), + ('contract_detail','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'), + ('contract_detail','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'), + ('contract_detail','deadline', 'contract_detail','deadline', 'TEXT', 'line'), + ('contract_detail','payment_scheme', 'contract_detail','payment_scheme','TEXT', 'line'), + ('contract_detail','act', 'contract_detail','act', 'TEXT', 'line'), + ('contract_detail','comment', 'contract_detail','comment', 'TEXT', 'line'), + ('contract_detail','contract_date', 'contract_detail','contract_date', 'DATE', 'line'), + + ('contract','counterparty', 'contract_detail','counterparty', 'TEXT', 'line'), + ('contract','addenda', 'contract_detail','addenda', 'TEXT', 'line'), + ('contract','amount_foreign', 'contract_detail','amount_foreign','NUMERIC','line'), + ('contract','q1', 'contract_detail','expenses_q1', 'NUMERIC','line'), + ('contract','q2', 'contract_detail','expenses_q2', 'NUMERIC','line'), + ('contract','q3', 'contract_detail','expenses_q3', 'NUMERIC','line'), + ('contract','q4', 'contract_detail','expenses_q4', 'NUMERIC','line'), + ('contract','rf_schedule', 'contract_detail','rf_schedule', 'TEXT', 'line'), + ('contract','exchange_rate', 'contract_detail','exchange_rate', 'NUMERIC','line'), + ('contract','reference', 'contract_detail','reference', 'TEXT', 'line'), + ('contract','date', 'contract_detail','contract_date', 'DATE', 'line'), + ('contract','subject', 'contract_detail','subject', 'TEXT', 'line'), + ('contract','currency', 'contract_detail','currency', 'TEXT', 'line'), + ('contract','ceiling', 'contract_detail','ceiling_amount','NUMERIC','line'), + ('contract','vat_rate', 'contract_detail','vat_rate', 'TEXT', 'line'), + ('contract','deadline', 'contract_detail','deadline', 'TEXT', 'line'), + ('contract','scheme', 'contract_detail','payment_scheme','TEXT', 'line'), + ('contract','act', 'contract_detail','act', 'TEXT', 'line'), + ('contract','comment', 'contract_detail','comment', 'TEXT', 'line'), + + ('sequestration','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('sequestration','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('sequestration','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('sequestration','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('sequestration','justification','sequestration','justification','TEXT', 'line_actor'), + + ('seq_dfip','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('seq_dfip','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('seq_dfip','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('seq_dfip','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('seq_dfip','justification','sequestration','justification','TEXT', 'line_actor'), + + ('seq_ssp','q1', 'sequestration','adj_q1', 'NUMERIC','line_actor'), + ('seq_ssp','q2', 'sequestration','adj_q2', 'NUMERIC','line_actor'), + ('seq_ssp','q3', 'sequestration','adj_q3', 'NUMERIC','line_actor'), + ('seq_ssp','q4', 'sequestration','adj_q4', 'NUMERIC','line_actor'), + ('seq_ssp','justification','sequestration','justification','TEXT', 'line_actor'), + + ('q','adj_current', 'budget_line_quarter','adj_current', 'NUMERIC','line_quarter'), + ('q','adj_ssp', 'budget_line_quarter','adj_ssp', 'NUMERIC','line_quarter'), + ('q','adj_rf', 'budget_line_quarter','adj_rf', 'NUMERIC','line_quarter'), + ('q','adj_reserve', 'budget_line_quarter','adj_reserve', 'NUMERIC','line_quarter'), + ('q','adj_comment', 'budget_line_quarter','adj_comment', 'TEXT', 'line_quarter'), + ('q','target_change', 'budget_line_quarter','target_change', 'NUMERIC','line_quarter'), + ('q','base_correction', 'budget_line_quarter','base_plan_correction', 'NUMERIC','line_quarter'), + ('q','base_correction_comment', 'budget_line_quarter','base_plan_correction_comment','TEXT', 'line_quarter'), + ('q','pay_date', 'budget_line_quarter','payment_date', 'DATE', 'line_quarter'), + ('q','pay_amount', 'budget_line_quarter','payment_amount', 'NUMERIC','line_quarter'), + ('q','pay_ho', 'budget_line_quarter','payment_amount_ho', 'NUMERIC','line_quarter'), + ('q','pay_rf', 'budget_line_quarter','payment_amount_rf', 'NUMERIC','line_quarter'), + ('q','pay_comment', 'budget_line_quarter','payment_comment', 'TEXT', 'line_quarter'), + ('q','pay_act', 'budget_line_quarter','payment_act', 'TEXT', 'line_quarter'), + ('q','actual_m1', 'budget_line_quarter','actual_m1', 'NUMERIC','line_quarter'), + ('q','actual_m2', 'budget_line_quarter','actual_m2', 'NUMERIC','line_quarter'), + ('q','actual_m3', 'budget_line_quarter','actual_m3', 'NUMERIC','line_quarter'), + ('q','actual_spod', 'budget_line_quarter','actual_spod', 'NUMERIC','line_quarter'), + ('q','transfer_q2', 'budget_line_quarter','transfer_to_q2', 'NUMERIC','line_quarter'), + ('q','transfer_q3', 'budget_line_quarter','transfer_to_q3', 'NUMERIC','line_quarter'), + ('q','transfer_q4', 'budget_line_quarter','transfer_to_q4', 'NUMERIC','line_quarter'), + ('q','transfer_econ', 'budget_line_quarter','transfer_to_economy', 'NUMERIC','line_quarter'), + ('q','transfer_q2_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q2_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q2_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_q3_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q3_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q3_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_q4_delay_acts', 'budget_line_quarter','transfer_delay_acts', 'NUMERIC','line_quarter'), + ('q','transfer_q4_delay_procurement', 'budget_line_quarter','transfer_delay_procurement', 'NUMERIC','line_quarter'), + ('q','transfer_q4_economy_rf', 'budget_line_quarter','transfer_economy_rf', 'NUMERIC','line_quarter'), + ('q','transfer_next_comment', 'budget_line_quarter','transfer_next_comment', 'TEXT', 'line_quarter'), + ('q','transfer_far_comment', 'budget_line_quarter','transfer_far_comment', 'TEXT', 'line_quarter'), + ('q','rev_eco', 'budget_line_quarter','plan_revision_eco_change','NUMERIC','line_quarter'), + ('q','rev_item', 'budget_line_quarter','plan_revision_item_adj', 'NUMERIC','line_quarter'), + ('q','rev_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'), + ('q','rev_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'), + ('q','rev_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'), + ('q','revision_inc', 'budget_line_quarter','plan_revision_increase', 'NUMERIC','line_quarter'), + ('q','revision_seq', 'budget_line_quarter','plan_revision_sequester', 'NUMERIC','line_quarter'), + ('q','revision_comment', 'budget_line_quarter','plan_revision_comment', 'TEXT', 'line_quarter'), + ('q','booking_amount', 'budget_line_quarter','booking_amount', 'NUMERIC','line_quarter') + ) AS m(scope, field, target_table, target_col, target_type, key_kind) + WHERE m.scope = (CASE WHEN v_q IS NOT NULL THEN 'q' ELSE v_scope END) + AND m.field = v_field; + + IF v_target_table IS NULL THEN + IF v_field IN ('corrected_plan','new_plan','actual_quarter','booking', + 'rem_booking','rem_actual','residual_after_booking', + 'residual_after_actual','economy','total','year') THEN + RAISE EXCEPTION 'computed_field: %', p_column; + END IF; + RAISE EXCEPTION 'unknown_column: %', p_column; + END IF; + + + IF v_key_kind = 'line' THEN + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, %2$I) VALUES ($1, ($2 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + EXECUTE v_sql USING p_line_id, p_value; + + ELSIF v_key_kind = 'line_quarter' THEN + IF v_q IS NULL THEN RAISE EXCEPTION 'internal: quarter not set for %', p_column; END IF; + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, quarter, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id, quarter) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + EXECUTE v_sql USING p_line_id, v_q, p_value; + + ELSIF v_key_kind = 'line_actor' THEN + IF v_actor IS NULL THEN RAISE EXCEPTION 'internal: actor not set for %', p_column; END IF; + v_sql := format( + 'INSERT INTO v3.%1$I (line_id, actor, %2$I) VALUES ($1, $2, ($3 #>> ''{}'')::%3$s) ' + || 'ON CONFLICT (line_id, actor) DO UPDATE SET %2$I = EXCLUDED.%2$I', + v_target_table, v_target_col, v_target_type + ); + EXECUTE v_sql USING p_line_id, v_actor, p_value; + + ELSE + RAISE EXCEPTION 'internal: unknown key_kind %', v_key_kind; + END IF; + + UPDATE v3.budget_line SET updated_at = now() WHERE id = p_line_id; +END; +$function$ +; + +UPDATE v3.phase_template +SET closes_at = closes_at - INTERVAL '2026 years', opens_at = opens_at - INTERVAL '2026 years'; diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index 79bd8cb..c01d488 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -45,6 +45,7 @@ def timezone_from_offset(offset: str) -> datetime.timezone | None: async def get_form_phases( form_id: int, sheet: str | None = None, + direction: str | None = None, phase_code: str | None = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), @@ -57,6 +58,7 @@ async def get_form_phases( budget_form_id=form_id, user=current_user, sheet=sheet, + direction=direction, phase_code=phase_code, ) if phases: @@ -117,6 +119,7 @@ async def update_form_phase( sheet: str, phase_code: str, body: FormPhaseUpdate, + direction: str | None = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_admin), ) -> BaseSingleResponse[FormPhaseResponse]: @@ -129,6 +132,7 @@ async def update_form_phase( phase = await fp_service.update( budget_form=form, sheet=sheet, + direction=direction, phase_code=phase_code, body=body, user=current_user, @@ -155,6 +159,7 @@ async def delete_form_phase( form_id: int, sheet: str, phase_code: str, + direction: str | None = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_admin), ): @@ -167,6 +172,7 @@ async def delete_form_phase( deleted = await fp_service.delete( budget_form_id=form_id, sheet=sheet, + direction=direction, phase_code=phase_code, user=current_user, ) diff --git a/api/src/db/models/form_phase.py b/api/src/db/models/form_phase.py index 32453c5..4774936 100644 --- a/api/src/db/models/form_phase.py +++ b/api/src/db/models/form_phase.py @@ -22,5 +22,6 @@ class FormPhase(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)) + direction: Mapped[str] = mapped_column(String) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/api/src/db/models/phase_template.py b/api/src/db/models/phase_template.py index d221fbf..90ae790 100644 --- a/api/src/db/models/phase_template.py +++ b/api/src/db/models/phase_template.py @@ -22,6 +22,7 @@ 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)) + direction: Mapped[str] = mapped_column(String) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index af51cc8..a6b4ee3 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -416,6 +416,7 @@ class FormPhaseCreate(BaseModel): column_keys: list[str] opens_at: datetime closes_at: datetime + direction: str | None = None class FormPhaseUpdate(BaseModel): diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py index b572de6..38bf8a4 100644 --- a/api/src/repository/form_phase_repository.py +++ b/api/src/repository/form_phase_repository.py @@ -14,23 +14,33 @@ class FormPhaseRepository: self, budget_form_id: int, sheet: str | None = None, + direction: str | None = None, phase_code: str | None = None, ) -> list[FormPhase]: query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id) if sheet is not None: - query = query.where(FormPhase.sheet == sheet) + query = query.where( + FormPhase.sheet == sheet, + FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction + ) + if phase_code is not None: query = query.where(FormPhase.phase_code == phase_code) return (await self.db.execute(query)).scalars().all() async def get( - self, budget_form_id: int, sheet: str, phase_code: str + self, + budget_form_id: int, + sheet: str, + direction: str | None, + phase_code: str, ) -> FormPhase | None: query = ( select(FormPhase) .where(FormPhase.budget_form_id == budget_form_id) .where(FormPhase.sheet == sheet) .where(FormPhase.phase_code == phase_code) + .where(FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction) .limit(1) ) return (await self.db.execute(query)).scalar_one_or_none() @@ -39,6 +49,7 @@ class FormPhaseRepository: self, budget_form_id: int, sheet: str, + direction: str | None, phase_code: str, role: str, column_keys: list[str], @@ -52,6 +63,7 @@ class FormPhaseRepository: (v3.add_form_phase( :budget_form_id, :sheet, + :direction, :phase_code, :role, :column_keys, @@ -63,6 +75,7 @@ class FormPhaseRepository: { "budget_form_id": budget_form_id, "sheet": sheet, + "direction": direction, "phase_code": phase_code, "role": role, "column_keys": column_keys, @@ -72,10 +85,14 @@ class FormPhaseRepository: ) if result.scalar_one_or_none() is None: return None - return await self.get(budget_form_id, sheet, phase_code) + return await self.get(budget_form_id, sheet, direction, phase_code) async def update( - self, budget_form_id: int, sheet: str, phase_code: str, data: dict + self, budget_form_id: int, + sheet: str, + direction: str | None, + phase_code: str, + data: dict, ) -> FormPhase | None: result = await self.db.execute( text( @@ -84,6 +101,7 @@ class FormPhaseRepository: (v3.upd_form_phase( :budget_form_id, :sheet, + :direction, :phase_code, :role, :column_keys, @@ -95,6 +113,7 @@ class FormPhaseRepository: { "budget_form_id": budget_form_id, "sheet": sheet, + "direction": direction, "phase_code": phase_code, "role": data.get("role"), "column_keys": data.get("column_keys"), @@ -104,18 +123,19 @@ class FormPhaseRepository: ) if result.scalar_one_or_none() is None: return None - return await self.get(budget_form_id, sheet, phase_code) + return await self.get(budget_form_id, sheet, direction, phase_code) async def delete( - self, budget_form_id: int, sheet: str, phase_code: str + self, budget_form_id: int, sheet: str, direction: str | None, phase_code: str ) -> bool: result = await self.db.execute( text( - "SELECT v3.del_form_phase(:budget_form_id, :sheet, :phase_code)" + "SELECT v3.del_form_phase(:budget_form_id, :sheet, :direction, :phase_code)" ), { "budget_form_id": budget_form_id, "sheet": sheet, + "direction": direction, "phase_code": phase_code, }, ) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index e17fd5e..bf7605f 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -50,7 +50,6 @@ class SheetRepository: func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)" else: func_query = "v3.v_form_view(:form_id, :sheet, :sections)" - return ( await self.db.execute( text( diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py index 4e80318..5ef9fb0 100644 --- a/api/src/services/form_phase_service.py +++ b/api/src/services/form_phase_service.py @@ -21,20 +21,23 @@ class FormPhaseService: budget_form_id: int, user: AppUser, sheet: str | None = None, + direction: str | None = None, phase_code: str | None = None, ) -> list[FormPhase]: return await self.fp_repo.get_list( budget_form_id=budget_form_id, sheet=sheet, + direction=direction, phase_code=phase_code, ) async def get( - self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser + self, budget_form_id: int, sheet: str, direction: str | None, phase_code: str, user: AppUser ) -> FormPhase | None: return await self.fp_repo.get( budget_form_id=budget_form_id, sheet=sheet, + direction=direction, phase_code=phase_code, ) @@ -43,12 +46,16 @@ class FormPhaseService: form_phase: FormPhaseCreate | FormPhaseUpdate, budget_form: BudgetForm, sheet: str | None = None, + direction: str | None = None, ) -> None: errors = {} - if budget_form.form_type.sheet_list: - sheet = sheet or form_phase.sheet - if sheet not in budget_form.form_type.sheet_list: - errors["sheet"] = [f"У формы нет листа {sheet}"] + if budget_form.form_type.sheet_list_with_directions: + if sheet: + sheet = {"sheet_name": sheet, "direction": direction} + else: + sheet = {"sheet_name": form_phase.sheet, "direction": form_phase.direction} + if sheet not in budget_form.form_type.sheet_list_with_directions: + errors["sheet"] = [f"У формы нет листа {sheet['sheet_name']}/{sheet['direction']}"] if form_phase.column_keys and budget_form.form_type.section_list: for column_key in form_phase.column_keys: section = column_key.split(".") @@ -84,6 +91,7 @@ class FormPhaseService: return await self.fp_repo.create( budget_form_id=budget_form_id or budget_form.id, sheet=body.sheet, + direction=body.direction, phase_code=body.phase_code, role=body.role, column_keys=body.column_keys, @@ -94,6 +102,7 @@ class FormPhaseService: async def update( self, sheet: str, + direction: str | None, phase_code: str, body: FormPhaseUpdate, user: AppUser, @@ -113,6 +122,7 @@ class FormPhaseService: form_phase=body, budget_form=budget_form, sheet=sheet, + direction=direction, ) data = body.model_dump(exclude_unset=True) @@ -120,20 +130,27 @@ class FormPhaseService: return await self.fp_repo.get( budget_form_id=budget_form_id or budget_form.id, sheet=sheet, + direction=direction, phase_code=phase_code, ) return await self.fp_repo.update( budget_form_id=budget_form_id or budget_form.id, sheet=sheet, + direction=direction, phase_code=phase_code, data=data, ) async def delete( - self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser + self, budget_form_id: int, + sheet: str, + direction: str | None, + phase_code: str, + user: AppUser, ) -> bool: return await self.fp_repo.delete( budget_form_id=budget_form_id, sheet=sheet, + direction=direction, phase_code=phase_code, ) diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 9e760bf..51a31c4 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -149,7 +149,7 @@ def test_form_phases_update(client, admin_tokens): def test_form_phases_delete(client, admin_tokens): response = client.delete( - "/api/v1/stages/form/1/AHR/test", + "/api/v1/stages/form/1/AHR/test?direction=Support", headers=_auth_headers(admin_tokens), ) assert response.status_code == 200 diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 154004d..4f85b3c 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -23,9 +23,10 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or (1,1,88,'1234',NULL,NULL,NULL,NULL,NULL,'2026-05-06 16:58:05.793083',NULL,'2026-06-22 17:41:54.255313','Support'); 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'), - (4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05'); +INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at,direction) VALUES + (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Support'), + (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Development'), + (4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05',NULL); 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',1,'','','',NULL), -- 2.47.2