diff --git a/api/alembic/versions/0059_form_phase_direction_fix.py b/api/alembic/versions/0059_form_phase_direction_fix.py new file mode 100644 index 0000000..a5c3699 --- /dev/null +++ b/api/alembic/versions/0059_form_phase_direction_fix.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0059" +down_revision = "0058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0059_form_phase_direction_fix.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0059_form_phase_direction_fix.sql b/api/alembic/versions/sql/0059_form_phase_direction_fix.sql new file mode 100644 index 0000000..28d9db5 --- /dev/null +++ b/api/alembic/versions/sql/0059_form_phase_direction_fix.sql @@ -0,0 +1,339 @@ +-- AURORA-1879: одноимённые этапы Support/Development. +-- 1. Чистка дублей этапов без направления и частичный уникальный индекс. +-- 2. direction в идентификаторе этапа в аудите (add/upd/del), direction в payload upd. +-- 3. del_form_phase удаляет только этап с указанным phase_code. + +DO $$ +DECLARE + v_deleted INT; +BEGIN + DELETE FROM v3.form_phase fp + WHERE fp.direction IS NULL + AND EXISTS ( + SELECT 1 FROM v3.form_phase o + WHERE o.direction IS NULL + AND o.budget_form_id = fp.budget_form_id + AND o.sheet = fp.sheet + AND o.phase_code = fp.phase_code + AND o.ctid > fp.ctid + ); + GET DIAGNOSTICS v_deleted = ROW_COUNT; + RAISE NOTICE 'form_phase: удалено дублей этапов без направления: %', v_deleted; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS form_phase_nodir_uidx + ON v3.form_phase (budget_form_id, sheet, phase_code) + WHERE direction IS NULL; + +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 := concat_ws(':', p_budget_form_id, p_sheet, p_direction, 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$ +; + +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)) + AND phase_code = p_phase_code + 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 := concat_ws(':', p_budget_form_id, p_sheet, p_direction, 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$ +; + +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 := concat_ws(':', p_budget_form_id, p_sheet, p_direction, 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, + 'direction', p_direction, + '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, + 'direction', p_direction, + 'phase_code', p_phase_code, + 'changes', v_changes + ), + p_budget_form_id, null, v_org_unit_id + ); + END IF; + + RETURN v_new; +END; +$function$ +; diff --git a/api/src/db/models/form_phase.py b/api/src/db/models/form_phase.py index 4774936..f7c1911 100644 --- a/api/src/db/models/form_phase.py +++ b/api/src/db/models/form_phase.py @@ -22,6 +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) + direction: Mapped[str | None] = mapped_column(String, primary_key=True, nullable=True) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py index 0c96c9f..1b9d7e5 100644 --- a/api/src/repository/form_phase_repository.py +++ b/api/src/repository/form_phase_repository.py @@ -29,6 +29,12 @@ class FormPhaseRepository: query = query.where(FormPhase.phase_code == phase_code) if role is not None: query = query.where(FormPhase.role == role) + query = query.order_by( + FormPhase.sheet, + FormPhase.direction.nulls_first(), + FormPhase.opens_at, + FormPhase.phase_code, + ) return (await self.db.execute(query)).scalars().all() async def get( diff --git a/api/tests/unit/test_form_phase_model.py b/api/tests/unit/test_form_phase_model.py new file mode 100644 index 0000000..5d58208 --- /dev/null +++ b/api/tests/unit/test_form_phase_model.py @@ -0,0 +1,11 @@ +from src.db.models.form_phase import FormPhase + + +def test_direction_in_primary_key(): + pk_names = {col.name for col in FormPhase.__mapper__.primary_key} + assert "direction" in pk_names + assert {"budget_form_id", "sheet", "phase_code"} <= pk_names + + +def test_direction_nullable(): + assert FormPhase.__table__.c.direction.nullable is True