From 9e15fad22699b138f8f5bc63ea0fa9af16bf9aaf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 16:39:24 +0400 Subject: [PATCH 1/6] =?UTF-8?q?fix(stages):=20direction=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD=D0=BE=D0=BC=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=20ORM=20FormPhase,=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0,=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=200059=20(AURORA-1879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../versions/0059_form_phase_direction_fix.py | 15 + .../sql/0059_form_phase_direction_fix.sql | 339 ++++++++++++++++++ api/src/db/models/form_phase.py | 2 +- api/src/repository/form_phase_repository.py | 6 + api/tests/unit/test_form_phase_model.py | 11 + 5 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/0059_form_phase_direction_fix.py create mode 100644 api/alembic/versions/sql/0059_form_phase_direction_fix.sql create mode 100644 api/tests/unit/test_form_phase_model.py 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 -- 2.47.2 From 92f6049a733d0a5f2935f5eb9815e1f7ab4c9c93 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 16:39:24 +0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(stages):=20FormPhaseService.create=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D1=8C=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=B8=D1=81=D1=82=D0=B5=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?(AURORA-1879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Интеграционные тесты одноимённых этапов, PATCH/DELETE одного этапа, дубль без направления, direction в аудите. Co-Authored-By: Claude Opus 5.5 --- api/src/services/form_phase_service.py | 13 +- .../integration/test_form_phases_api_smoke.py | 119 ++++++++++++++++++ api/tests/unit/test_form_phase_service.py | 33 +++++ api/tests/unit/test_form_phase_service_tz.py | 1 + 4 files changed, 165 insertions(+), 1 deletion(-) diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py index e76bfd1..fdd85f5 100644 --- a/api/src/services/form_phase_service.py +++ b/api/src/services/form_phase_service.py @@ -106,10 +106,21 @@ class FormPhaseService: form_phase=body, budget_form=budget_form, ) + budget_form_id = budget_form_id or budget_form.id opens_at = to_org_local_naive(body.opens_at, utc_offset) closes_at = to_org_local_naive(body.closes_at, utc_offset) + existing = await self.fp_repo.get( + budget_form_id=budget_form_id, + sheet=body.sheet, + direction=body.direction, + phase_code=body.phase_code, + ) + if existing: + raise ValidationsError( + errors={"phase_code": ["Этап с таким названием уже есть на этом листе и направлении"]} + ) return await self.fp_repo.create( - budget_form_id=budget_form_id or budget_form.id, + budget_form_id=budget_form_id, sheet=body.sheet, direction=body.direction, phase_code=body.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 bd3901a..c736f50 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -421,3 +421,122 @@ def test_form_phases_create_aware_dates_keep_instant(client, admin_tokens): assert _instant(patched["opens_at"]) == _instant(body["opens_at"]) finally: client.delete("/api/v1/stages/form/4/AHR/tz_aware_phase", headers=headers) + + +# AURORA-1879: одноимённые этапы Support/Development и дубли на листе без направления. +def _phase_body(phase_code, role, direction, opens="2026-01-01T00:00:00", closes="2026-12-31T00:00:00", sheet="AHR"): + return { + "sheet": sheet, + "phase_code": phase_code, + "role": role, + "column_keys": ["plan.q1"], + "opens_at": opens, + "closes_at": closes, + "direction": direction, + } + + +def _create_same_name_phases(client, headers): + sup = _phase_body("T_same", "DFIP", "Support") + dev = _phase_body("T_same", "EXECUTOR_RF", "Development", "2026-02-01T00:00:00", "2026-11-30T00:00:00") + assert client.post("/api/v1/stages/form/1", json=sup, headers=headers).status_code == 200 + assert client.post("/api/v1/stages/form/1", json=dev, headers=headers).status_code == 200 + + +def _list_same(client, headers, query=""): + response = client.get(f"/api/v1/stages/form/1?phase_code=T_same{query}", headers=headers) + assert response.status_code == 200 + return response.json()["result"] + + +def test_form_phases_same_name_both_directions_listed(client, admin_tokens): + headers = _auth_headers(admin_tokens) + _create_same_name_phases(client, headers) + + result = _list_same(client, headers) + by_direction = {p["direction"]: p for p in result} + assert len(result) == 2 + assert by_direction["Support"]["role"] == "DFIP" + assert by_direction["Development"]["role"] == "EXECUTOR_RF" + assert by_direction["Support"]["opens_at"][:10] == "2026-01-01" + assert by_direction["Development"]["opens_at"][:10] == "2026-02-01" + + only_dev = _list_same(client, headers, "&sheet=AHR&direction=Development") + assert len(only_dev) == 1 + assert only_dev[0]["direction"] == "Development" + + +def test_form_phases_patch_touches_only_one_direction(client, admin_tokens): + headers = _auth_headers(admin_tokens) + _create_same_name_phases(client, headers) + + response = client.patch( + "/api/v1/stages/form/1/AHR/T_same?direction=Development", + json={"role": "DFIP"}, + headers=headers, + ) + assert response.status_code == 200 + by_direction = {p["direction"]: p for p in _list_same(client, headers)} + assert by_direction["Development"]["role"] == "DFIP" + assert by_direction["Support"]["role"] == "DFIP" + assert by_direction["Support"]["opens_at"][:10] == "2026-01-01" + assert by_direction["Support"]["closes_at"][:10] == "2026-12-31" + assert by_direction["Development"]["closes_at"][:10] == "2026-11-30" + + +def test_form_phases_delete_removes_only_named_phase(client, admin_tokens): + headers = _auth_headers(admin_tokens) + _create_same_name_phases(client, headers) + other = _phase_body("T_other", "DFIP", "Support") + assert client.post("/api/v1/stages/form/1", json=other, headers=headers).status_code == 200 + + response = client.delete("/api/v1/stages/form/1/AHR/T_same?direction=Support", headers=headers) + assert response.status_code == 200 + + result = client.get("/api/v1/stages/form/1?sheet=AHR", headers=headers).json()["result"] + left = {(p["phase_code"], p["direction"]) for p in result if p["phase_code"] in ("T_same", "T_other")} + assert left == {("T_other", "Support"), ("T_same", "Development")} + + +def test_form_phases_delete_unknown_phase_code_404(client, admin_tokens): + headers = _auth_headers(admin_tokens) + # в фикстуре на форме 1, лист AHR, Support один этап "test" + before = client.get("/api/v1/stages/form/1?sheet=AHR&direction=Support", headers=headers).json()["result"] + response = client.delete("/api/v1/stages/form/1/AHR/no_such_phase?direction=Support", headers=headers) + assert response.status_code == 404 + after = client.get("/api/v1/stages/form/1?sheet=AHR&direction=Support", headers=headers).json()["result"] + assert [p["phase_code"] for p in after] == [p["phase_code"] for p in before] + assert any(p["phase_code"] == "test" for p in after) + + +def test_form_phases_duplicate_without_direction_rejected(client, admin_tokens): + headers = _auth_headers(admin_tokens) + body = _phase_body("T_dup", "DFIP", None, sheet="OPER") + first = client.post("/api/v1/stages/form/1", json=body, headers=headers) + assert first.status_code == 200 + second = client.post("/api/v1/stages/form/1", json=body, headers=headers) + assert 400 <= second.status_code < 500 + assert "phase_code" in second.text + + result = client.get("/api/v1/stages/form/1?sheet=OPER&phase_code=T_dup", headers=headers).json()["result"] + assert len(result) == 1 + + +def test_form_phases_patch_audit_has_direction(client, admin_tokens, svod2_sql): + headers = _auth_headers(admin_tokens) + _create_same_name_phases(client, headers) + response = client.patch( + "/api/v1/stages/form/1/AHR/T_same?direction=Development", + json={"role": "DFIP"}, + headers=headers, + ) + assert response.status_code == 200 + + rows = svod2_sql( + "SELECT event, event_data->>'direction', event_data->>'phase_id' FROM v3.audit_log " + "WHERE event LIKE 'ACCESS_%' ORDER BY id DESC LIMIT 1" + ) + assert rows, "аудит ACCESS_* не записан" + event, direction, phase_id = rows[0] + assert direction == "Development" + assert phase_id == "1:AHR:Development:T_same" diff --git a/api/tests/unit/test_form_phase_service.py b/api/tests/unit/test_form_phase_service.py index a89db12..0664f22 100644 --- a/api/tests/unit/test_form_phase_service.py +++ b/api/tests/unit/test_form_phase_service.py @@ -60,3 +60,36 @@ async def test_get_list_no_role_filter_for_admin(service_with_mock_repo, admin_u phase_code=None, role=None, ) + + +@pytest.mark.asyncio +async def test_create_rejects_existing_phase(service_with_mock_repo, admin_user): + from src.core.errors import ValidationsError + from src.domain.schemas import FormPhaseCreate + + service, mock_repo = service_with_mock_repo + mock_repo.get = AsyncMock(return_value=SimpleNamespace(phase_code="T_dup")) + mock_repo.create = AsyncMock() + body = FormPhaseCreate( + sheet="OPER", + phase_code="T_dup", + role="DFIP", + column_keys=["plan.q1"], + opens_at="2026-01-01T00:00:00", + closes_at="2026-12-31T00:00:00", + direction=None, + ) + budget_form = SimpleNamespace( + id=1, form_type=SimpleNamespace(sheet_list_with_directions=None, section_list=None) + ) + + with pytest.raises(ValidationsError) as exc: + await service.create(body=body, user=admin_user, budget_form=budget_form) + + assert exc.value.errors == { + "phase_code": ["Этап с таким названием уже есть на этом листе и направлении"] + } + mock_repo.get.assert_awaited_once_with( + budget_form_id=1, sheet="OPER", direction=None, phase_code="T_dup" + ) + mock_repo.create.assert_not_awaited() diff --git a/api/tests/unit/test_form_phase_service_tz.py b/api/tests/unit/test_form_phase_service_tz.py index 019a58a..5e994db 100644 --- a/api/tests/unit/test_form_phase_service_tz.py +++ b/api/tests/unit/test_form_phase_service_tz.py @@ -56,6 +56,7 @@ def _admin(): def _form_service(): service = FormPhaseService(db=AsyncMock()) service.fp_repo = AsyncMock() + service.fp_repo.get.return_value = None # этапа с таким именем ещё нет form = SimpleNamespace( id=1, form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None), -- 2.47.2 From 8798f092c4ff8bbb279502e4583ebeb624927565 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 16:53:42 +0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(stages):=20=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20dollar-=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=20=D0=B2=20DO-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B5=20005?= =?UTF-8?q?9,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=B2=20(AURORA-1879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sql_migration._split_statements узнаёт только $tag$, пустой $$ ломал разбиение: DO $$ … $$ → DO $do$ … $do$. - test_form_phases_delete_removes_only_named_phase: ?sheet= без direction фильтрует direction IS NULL — читаем весь список и отбираем AHR в тесте. - test_form_phases_duplicate_without_direction_rejected: после ошибки сессия клиента откатывается, число строк не проверяем; защиту БД проверяет новый test_form_phase_nodir_unique_index_blocks_duplicate. Co-Authored-By: Claude Opus 5.5 --- .../sql/0059_form_phase_direction_fix.sql | 4 +-- .../integration/test_form_phases_api_smoke.py | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/api/alembic/versions/sql/0059_form_phase_direction_fix.sql b/api/alembic/versions/sql/0059_form_phase_direction_fix.sql index 28d9db5..0f0455b 100644 --- a/api/alembic/versions/sql/0059_form_phase_direction_fix.sql +++ b/api/alembic/versions/sql/0059_form_phase_direction_fix.sql @@ -3,7 +3,7 @@ -- 2. direction в идентификаторе этапа в аудите (add/upd/del), direction в payload upd. -- 3. del_form_phase удаляет только этап с указанным phase_code. -DO $$ +DO $do$ DECLARE v_deleted INT; BEGIN @@ -20,7 +20,7 @@ BEGIN GET DIAGNOSTICS v_deleted = ROW_COUNT; RAISE NOTICE 'form_phase: удалено дублей этапов без направления: %', v_deleted; END -$$; +$do$; CREATE UNIQUE INDEX IF NOT EXISTS form_phase_nodir_uidx ON v3.form_phase (budget_form_id, sheet, 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 c736f50..d17ef42 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -493,8 +493,13 @@ def test_form_phases_delete_removes_only_named_phase(client, admin_tokens): response = client.delete("/api/v1/stages/form/1/AHR/T_same?direction=Support", headers=headers) assert response.status_code == 200 - result = client.get("/api/v1/stages/form/1?sheet=AHR", headers=headers).json()["result"] - left = {(p["phase_code"], p["direction"]) for p in result if p["phase_code"] in ("T_same", "T_other")} + # ?sheet= без direction фильтрует direction IS NULL — берём весь список и отбираем AHR здесь. + result = client.get("/api/v1/stages/form/1", headers=headers).json()["result"] + left = { + (p["phase_code"], p["direction"]) + for p in result + if p["sheet"] == "AHR" and p["phase_code"] in ("T_same", "T_other") + } assert left == {("T_other", "Support"), ("T_same", "Development")} @@ -517,9 +522,23 @@ def test_form_phases_duplicate_without_direction_rejected(client, admin_tokens): second = client.post("/api/v1/stages/form/1", json=body, headers=headers) assert 400 <= second.status_code < 500 assert "phase_code" in second.text + # После ошибки клиентская сессия откатывается целиком (tests/integration/conftest.py), + # поэтому число строк здесь не проверяем — защиту БД проверяет следующий тест. - result = client.get("/api/v1/stages/form/1?sheet=OPER&phase_code=T_dup", headers=headers).json()["result"] - assert len(result) == 1 + +def test_form_phase_nodir_unique_index_blocks_duplicate(client, svod2_sql): + rows = svod2_sql( + "SELECT budget_form_id, sheet, phase_code FROM v3.form_phase WHERE direction IS NULL LIMIT 1" + ) + if not rows: + pytest.skip("Нет этапа без направления в текущей БД") + form_id, sheet, phase_code = rows[0] + with pytest.raises(Exception, match="form_phase_nodir_uidx"): + svod2_sql( + "INSERT INTO v3.form_phase SELECT * FROM v3.form_phase " + "WHERE budget_form_id = :f AND sheet = :s AND phase_code = :p AND direction IS NULL", + {"f": form_id, "s": sheet, "p": phase_code}, + ) def test_form_phases_patch_audit_has_direction(client, admin_tokens, svod2_sql): -- 2.47.2 From af44b98d7aad031ab30947497f704f670d20aecb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 17:05:09 +0400 Subject: [PATCH 4/6] =?UTF-8?q?fix(stages):=20=D1=8D=D1=82=D0=B0=D0=BF=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=BE=20=D0=BB=D0=B8=D1=81=D1=82=D1=83,?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=B8=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E;=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=B8=20direct?= =?UTF-8?q?ion=20=D0=B2=20params=20(AURORA-1880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/stages.js | 28 ++++++++++--------------- web/src/api/stagesRequest.js | 4 ++++ web/src/api/stagesRequest.test.js | 16 ++++++++++++++ web/src/components/Stages/utils.js | 8 +++++++ web/src/components/Stages/utils.test.js | 28 +++++++++++++++++++++++++ web/src/hooks/useTaskStages.js | 11 +++++----- 6 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 web/src/api/stagesRequest.js create mode 100644 web/src/api/stagesRequest.test.js create mode 100644 web/src/components/Stages/utils.test.js diff --git a/web/src/api/stages.js b/web/src/api/stages.js index 1049600..c33be3f 100644 --- a/web/src/api/stages.js +++ b/web/src/api/stages.js @@ -1,28 +1,22 @@ import api from "./client"; +import { buildFormStageRequest } from "./stagesRequest"; export const StagesApi = { createStage(formId, payload) { return api.post(`/stages/form/${formId}`, payload).then((r) => r.data); }, updateStage(payload) { - const { budget_form_id, sheet, phase_code, direction } = payload; - if (direction) { - return api - .patch( - `/stages/form/${budget_form_id}/${sheet}/${phase_code}?direction=${direction}`, - payload, - ) - .then((r) => r.data); - } - - return api - .patch(`/stages/form/${budget_form_id}/${sheet}/${phase_code}`, payload) - .then((r) => r.data); + const { path, params } = buildFormStageRequest(payload); + return api.patch(path, payload, { params }).then((r) => r.data); }, - deleteStage(formId, sheet, phaseCode) { - return api - .delete(`/stages/form/${formId}/${sheet}/${phaseCode}`) - .then((r) => r.data); + deleteStage(formId, sheet, phaseCode, direction) { + const { path, params } = buildFormStageRequest({ + budget_form_id: formId, + sheet, + phase_code: phaseCode, + direction, + }); + return api.delete(path, { params }).then((r) => r.data); }, getStages(formId) { return api.get(`/stages/form/${formId}`).then((r) => r.data); diff --git a/web/src/api/stagesRequest.js b/web/src/api/stagesRequest.js new file mode 100644 index 0000000..9a95302 --- /dev/null +++ b/web/src/api/stagesRequest.js @@ -0,0 +1,4 @@ +export const buildFormStageRequest = ({ budget_form_id, sheet, phase_code, direction }) => ({ + path: `/stages/form/${budget_form_id}/${encodeURIComponent(sheet)}/${encodeURIComponent(phase_code)}`, + params: direction ? { direction } : {}, +}); diff --git a/web/src/api/stagesRequest.test.js b/web/src/api/stagesRequest.test.js new file mode 100644 index 0000000..5c5b1b6 --- /dev/null +++ b/web/src/api/stagesRequest.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildFormStageRequest } from './stagesRequest.js'; + +test('кодирует sheet и phase_code в пути', () => { + const { path } = buildFormStageRequest({ budget_form_id: 5, sheet: 'AHR', phase_code: 'А/Б?#' }); + assert.equal(path, `/stages/form/5/AHR/${encodeURIComponent('А/Б?#')}`); + assert.ok(!path.slice('/stages/form/5/AHR/'.length).includes('/')); +}); + +test('direction уходит в params, без него params пустые', () => { + const a = buildFormStageRequest({ budget_form_id: 5, sheet: 'AHR', phase_code: 'x', direction: 'Support' }); + assert.deepEqual(a.params, { direction: 'Support' }); + const b = buildFormStageRequest({ budget_form_id: 5, sheet: 'OPER', phase_code: 'x', direction: null }); + assert.deepEqual(b.params, {}); +}); diff --git a/web/src/components/Stages/utils.js b/web/src/components/Stages/utils.js index c4aaae9..7936ca3 100644 --- a/web/src/components/Stages/utils.js +++ b/web/src/components/Stages/utils.js @@ -6,3 +6,11 @@ export const isPastDate = (value) => { today.setHours(0, 0, 0, 0); return date < today; }; + +export const isSameStage = (a, b) => + a.budget_form_id === b.budget_form_id && + a.sheet === b.sheet && + a.phase_code === b.phase_code && + (a.direction ?? null) === (b.direction ?? null); + +export const getStageKey = (stage) => [stage.sheet, stage.direction ?? '', stage.phase_code].join('|'); diff --git a/web/src/components/Stages/utils.test.js b/web/src/components/Stages/utils.test.js new file mode 100644 index 0000000..c190fc5 --- /dev/null +++ b/web/src/components/Stages/utils.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getStageKey, isSameStage } from './utils.js'; + +const base = { budget_form_id: 1, sheet: 'AHR', phase_code: 'Сбор', direction: 'Support' }; + +test('isSameStage различает Support и Development', () => { + assert.equal(isSameStage(base, { ...base, direction: 'Development' }), false); + assert.equal(isSameStage(base, { ...base }), true); +}); + +test('isSameStage: null и undefined direction равны', () => { + const a = { budget_form_id: 1, sheet: 'OPER', phase_code: 'X', direction: null }; + const b = { budget_form_id: 1, sheet: 'OPER', phase_code: 'X' }; + assert.equal(isSameStage(a, b), true); + assert.equal(isSameStage(b, a), true); +}); + +test('isSameStage: разные budget_form_id или phase_code - разные этапы', () => { + assert.equal(isSameStage(base, { ...base, budget_form_id: 2 }), false); + assert.equal(isSameStage(base, { ...base, phase_code: 'Другой' }), false); + assert.equal(isSameStage(base, { ...base, sheet: 'CAP' }), false); +}); + +test('getStageKey различается у пары Support/Development с одинаковым названием', () => { + assert.notEqual(getStageKey(base), getStageKey({ ...base, direction: 'Development' })); + assert.equal(getStageKey({ sheet: 'OPER', phase_code: 'X' }), 'OPER||X'); +}); diff --git a/web/src/hooks/useTaskStages.js b/web/src/hooks/useTaskStages.js index 84400f3..9bcae5a 100644 --- a/web/src/hooks/useTaskStages.js +++ b/web/src/hooks/useTaskStages.js @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react'; import { toast } from 'react-toastify'; import { StagesApi } from '../api/stages'; +import { isSameStage } from '../components/Stages/utils'; export const useTaskStages = (taskId) => { const [stagesInfo, setStagesInfo] = useState({ @@ -37,9 +38,7 @@ export const useTaskStages = (taskId) => { .then((data) => { setStagesInfo((prev) => ({ ...prev, - stages: prev.stages.map((curStage) => - curStage.sheet === stage.sheet && curStage.phase_code === stage.phase_code ? data.result : curStage, - ), + stages: prev.stages.map((curStage) => (isSameStage(curStage, stage) ? data.result : curStage)), })); toast.success('Данные об этапе обновлены'); return data; @@ -83,14 +82,14 @@ export const useTaskStages = (taskId) => { const deleteStage = useCallback( (stage) => { - const { sheet, phase_code, budget_form_id } = stage; + const { sheet, phase_code, budget_form_id, direction } = stage; if (!budget_form_id || !sheet || !phase_code) return; - StagesApi.deleteStage(budget_form_id, sheet, phase_code) + StagesApi.deleteStage(budget_form_id, sheet, phase_code, direction) .then(() => { setStagesInfo((prev) => ({ ...prev, - stages: prev.stages.filter((curStage) => !(curStage.sheet === sheet && curStage.phase_code === phase_code)), + stages: prev.stages.filter((curStage) => !isSameStage(curStage, stage)), })); toast.success('Этап успешно удален'); }) -- 2.47.2 From 5e364ac807dfa2e58c4530f27f43a1d9a398cfe1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 17:05:09 +0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(stages):=20=D0=BD=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B2=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=20(AURORA-1880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/components/Stages/DeleteStageModal.jsx | 8 ++++++++ web/src/components/Stages/SettingModal.jsx | 4 ++-- web/src/components/Stages/StagesTable/StagesTable.jsx | 11 ++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/web/src/components/Stages/DeleteStageModal.jsx b/web/src/components/Stages/DeleteStageModal.jsx index f345a8c..09de116 100644 --- a/web/src/components/Stages/DeleteStageModal.jsx +++ b/web/src/components/Stages/DeleteStageModal.jsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import { Button, Stack, Typography } from '@mui/material'; +import { DIRECTION_TRANSLATE, SHEET_NAME } from '../../constants/constants'; import { ModalContainer as BaseModalContainer, ModalBackdrop } from '../common/Modal/FormModalStyled'; const ModalContainer = styled(BaseModalContainer)` @@ -40,6 +41,13 @@ export const DeleteStageModal = ({ isOpen, onClose, onConfirm, stage }) => { Удалить этот элемент? + {stage && ( + + {SHEET_NAME[stage.sheet] ?? stage.sheet} + {stage.direction ? ` (${DIRECTION_TRANSLATE[stage.direction] ?? stage.direction})` : ''} — {stage.phase_code} + + )} + Он будет удалён без возможности восстановления. diff --git a/web/src/components/Stages/SettingModal.jsx b/web/src/components/Stages/SettingModal.jsx index 39578f8..ad213bd 100644 --- a/web/src/components/Stages/SettingModal.jsx +++ b/web/src/components/Stages/SettingModal.jsx @@ -122,7 +122,7 @@ export const SettingModal = ({ )} {isAdmin && ( @@ -141,7 +141,7 @@ export const SettingModal = ({ defaultSheet={mode === 'project' ? reportType : ''} /> )} - {!isTaskMode && ( + {isAdmin && ( { diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx index bac0e40..04bd411 100644 --- a/web/src/components/Stages/StagesTable/StagesTable.jsx +++ b/web/src/components/Stages/StagesTable/StagesTable.jsx @@ -2,9 +2,10 @@ import MoreVertIcon from '@mui/icons-material/MoreVert'; import { Divider, IconButton, Menu, MenuItem, TableBody, TableCell, TableRow, Typography } from '@mui/material'; import { useState } from 'react'; import { useAuth } from '../../../app/context/AuthProvider'; -import { ROLES_NAME_ID, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../constants/constants'; +import { DIRECTION_TRANSLATE, ROLES_NAME_ID, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../constants/constants'; import { formatDate } from '../../../utils/formatDate'; import { StageStatusChip } from '../StageStatusChip'; +import { getStageKey } from '../utils'; import { StyledTable, StyledTableContainer, StyledTableHead, StyledTableHeadRow, StyledTableRow } from './StagesTable.style'; import { getStageStatus, getStatusColors } from './utils'; @@ -60,15 +61,15 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => { ) : ( - stages.map((stage, index) => { + stages.map((stage) => { const status = getStageStatus(stage); const colors = getStatusColors(status); - const rowKey = `${stage.sheet}-${stage.phase_code}-${stage.role}-${index}`; return ( - + - {SHEET_NAME[stage.sheet]} - {stage.phase_code} + {SHEET_NAME[stage.sheet]} + {stage.direction ? ` (${DIRECTION_TRANSLATE[stage.direction] ?? stage.direction})` : ''} — {stage.phase_code} {isAdmin && ( -- 2.47.2 From 7a5c89eca697d4294f443999fff545b6eefd1586 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 19:36:40 +0400 Subject: [PATCH 6/6] =?UTF-8?q?AURORA-1879:=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=200059=20=E2=86=92=200060=20(0059=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B0=20AURORA-1984,=20PR=20#306?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...se_direction_fix.py => 0060_form_phase_direction_fix.py} | 6 +++--- ..._direction_fix.sql => 0060_form_phase_direction_fix.sql} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename api/alembic/versions/{0059_form_phase_direction_fix.py => 0060_form_phase_direction_fix.py} (58%) rename api/alembic/versions/sql/{0059_form_phase_direction_fix.sql => 0060_form_phase_direction_fix.sql} (100%) diff --git a/api/alembic/versions/0059_form_phase_direction_fix.py b/api/alembic/versions/0060_form_phase_direction_fix.py similarity index 58% rename from api/alembic/versions/0059_form_phase_direction_fix.py rename to api/alembic/versions/0060_form_phase_direction_fix.py index a5c3699..df8f370 100644 --- a/api/alembic/versions/0059_form_phase_direction_fix.py +++ b/api/alembic/versions/0060_form_phase_direction_fix.py @@ -1,14 +1,14 @@ from sql_migration import run_sql_file_migration -revision = "0059" -down_revision = "0058" +revision = "0060" +down_revision = "0059" branch_labels = None depends_on = None def upgrade() -> None: - run_sql_file_migration("0059_form_phase_direction_fix.sql") + run_sql_file_migration("0060_form_phase_direction_fix.sql") def downgrade() -> None: diff --git a/api/alembic/versions/sql/0059_form_phase_direction_fix.sql b/api/alembic/versions/sql/0060_form_phase_direction_fix.sql similarity index 100% rename from api/alembic/versions/sql/0059_form_phase_direction_fix.sql rename to api/alembic/versions/sql/0060_form_phase_direction_fix.sql -- 2.47.2