fix(stages): direction в первичном ключе ORM FormPhase, детерминированный порядок списка, миграция 0059 (AURORA-1879)
- ORM: direction входит в PK (nullable), одноимённые этапы Support/Development больше не склеиваются - get_list: ORDER BY sheet, direction (NULL первыми), opens_at, phase_code - 0059: del_form_phase фильтрует по phase_code, direction в v_phase_id (add/upd/del) и в payload upd, чистка дублей без направления и частичный уникальный индекс form_phase_nodir_uidx Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a91f35ab2
commit
9e15fad226
15
api/alembic/versions/0059_form_phase_direction_fix.py
Normal file
15
api/alembic/versions/0059_form_phase_direction_fix.py
Normal file
@ -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
|
||||
339
api/alembic/versions/sql/0059_form_phase_direction_fix.sql
Normal file
339
api/alembic/versions/sql/0059_form_phase_direction_fix.sql
Normal file
@ -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$
|
||||
;
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
11
api/tests/unit/test_form_phase_model.py
Normal file
11
api/tests/unit/test_form_phase_model.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user