From 9e15fad22699b138f8f5bc63ea0fa9af16bf9aaf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 16:39:24 +0400 Subject: [PATCH 1/8] =?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/8] =?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/8] =?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/8] =?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/8] =?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 63c368902b691b7a38b1bdfcbbf7de8258a8be20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 17:09:56 +0400 Subject: [PATCH 6/8] =?UTF-8?q?feat(stages):=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2,=20now=20=D0=B2?= =?UTF-8?q?=20getStageStatus=20(AURORA-1881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/Stages/StagesTable/utils.js | 4 +- web/src/components/Stages/stageFilters.js | 42 ++++++++++ .../components/Stages/stageFilters.test.js | 83 +++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 web/src/components/Stages/stageFilters.js create mode 100644 web/src/components/Stages/stageFilters.test.js diff --git a/web/src/components/Stages/StagesTable/utils.js b/web/src/components/Stages/StagesTable/utils.js index 67b31b0..0f9f2b4 100644 --- a/web/src/components/Stages/StagesTable/utils.js +++ b/web/src/components/Stages/StagesTable/utils.js @@ -1,4 +1,4 @@ -export const getStageStatus = (stage) => { +export const getStageStatus = (stage, nowDate = new Date()) => { const start = stage.opens_at; const end = stage.closes_at; @@ -6,7 +6,7 @@ export const getStageStatus = (stage) => { return 'Не определен'; } - const now = new Date(); + const now = new Date(nowDate); now.setHours(0, 0, 0, 0); const startDate = new Date(start); diff --git a/web/src/components/Stages/stageFilters.js b/web/src/components/Stages/stageFilters.js new file mode 100644 index 0000000..4fdb979 --- /dev/null +++ b/web/src/components/Stages/stageFilters.js @@ -0,0 +1,42 @@ +import { getStageStatus } from './StagesTable/utils.js'; + +export const EMPTY_STAGE_FILTERS = { search: '', sheetKey: '', role: '', status: '' }; + +export const DIRECTION_ORDER = ['Support', 'Development']; + +export const getSheetKey = ({ sheet, direction }) => `${sheet}|${direction ?? ''}`; + +const normalize = (value) => String(value ?? '').trim().toLowerCase().replaceAll('ё', 'е'); + +export const filterStages = (stages, filters = EMPTY_STAGE_FILTERS, now = new Date()) => { + const { search = '', sheetKey = '', role = '', status = '' } = filters ?? {}; + const query = normalize(search); + return (stages ?? []).filter((stage) => { + if (sheetKey && getSheetKey(stage) !== sheetKey) return false; + if (role && stage.role !== role) return false; + if (status && getStageStatus(stage, now) !== status) return false; + if (query && !normalize(stage.phase_code).includes(query)) return false; + return true; + }); +}; + +const rank = (list, value) => { + const index = list.indexOf(value); + return index === -1 ? list.length : index; +}; + +const time = (value) => (value ? new Date(value).getTime() : Number.POSITIVE_INFINITY); + +export const sortStages = (stages, sheetOrder = []) => + [...(stages ?? [])].sort((a, b) => { + const bySheet = rank(sheetOrder, a.sheet) - rank(sheetOrder, b.sheet); + if (bySheet !== 0) return bySheet; + // направления: Support, Development, затем без направления + const dirRank = (s) => (s.direction ? rank(DIRECTION_ORDER, s.direction) : DIRECTION_ORDER.length + 1); + const byDirection = dirRank(a) - dirRank(b); + if (byDirection !== 0) return byDirection; + const ta = time(a.opens_at); + const tb = time(b.opens_at); + if (ta !== tb) return ta < tb ? -1 : 1; + return String(a.phase_code ?? '').localeCompare(String(b.phase_code ?? ''), 'ru'); + }); diff --git a/web/src/components/Stages/stageFilters.test.js b/web/src/components/Stages/stageFilters.test.js new file mode 100644 index 0000000..0147c0f --- /dev/null +++ b/web/src/components/Stages/stageFilters.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EMPTY_STAGE_FILTERS, filterStages, sortStages } from './stageFilters.js'; + +const NOW = new Date(2026, 10, 11); +const mk = (sheet, direction, phase_code, extra = {}) => ({ + budget_form_id: 1, + sheet, + direction, + phase_code, + role: 'executor', + opens_at: '2026-11-01', + closes_at: '2026-11-30', + ...extra, +}); + +const stages = [ + mk('AHR', 'Support', 'Сбор'), + mk('AHR', 'Development', 'Сбор'), + mk('OPER', null, 'Сбор'), + mk('OPER', null, 'Ёлка', { role: 'approver', opens_at: '2026-01-01', closes_at: '2026-02-01' }), + mk('AHR', 'Support', 'Будущее', { opens_at: '2027-01-01', closes_at: '2027-02-01' }), +]; + +const f = (o) => ({ ...EMPTY_STAGE_FILTERS, ...o }); + +test('пустые фильтры возвращают всё', () => { + assert.equal(filterStages(stages, EMPTY_STAGE_FILTERS, NOW).length, stages.length); + assert.equal(filterStages(stages, undefined, NOW).length, stages.length); +}); + +test('AHR|Development не пропускает AHR|Support с тем же названием', () => { + const res = filterStages(stages, f({ sheetKey: 'AHR|Development' }), NOW); + assert.equal(res.length, 1); + assert.equal(res[0].direction, 'Development'); +}); + +test('OPER| пропускает только этапы без направления', () => { + const res = filterStages(stages, f({ sheetKey: 'OPER|' }), NOW); + assert.equal(res.length, 2); + assert.ok(res.every((s) => s.sheet === 'OPER' && !s.direction)); +}); + +test('фильтр по статусу при фиксированном now', () => { + assert.equal(filterStages(stages, f({ status: 'Активен' }), NOW).length, 3); + assert.equal(filterStages(stages, f({ status: 'Завершен' }), NOW)[0].phase_code, 'Ёлка'); + assert.equal(filterStages(stages, f({ status: 'Запланирован' }), NOW)[0].phase_code, 'Будущее'); +}); + +test('поиск без учёта регистра, кириллица и Ё', () => { + assert.equal(filterStages(stages, f({ search: 'сбОР' }), NOW).length, 3); + assert.equal(filterStages(stages, f({ search: 'ёлка' }), NOW).length, 1); + assert.equal(filterStages(stages, f({ search: 'ЁЛКА' }), NOW).length, 1); + assert.equal(filterStages(stages, f({ search: ' сбор ' }), NOW).length, 3); +}); + +test('фильтр роли', () => { + const res = filterStages(stages, f({ role: 'approver' }), NOW); + assert.equal(res.length, 1); + assert.equal(res[0].phase_code, 'Ёлка'); +}); + +test('фильтры комбинируются', () => { + const res = filterStages(stages, f({ sheetKey: 'AHR|Support', status: 'Активен' }), NOW); + assert.equal(res.length, 1); +}); + +test('sortStages: лист по порядку sheets, затем направление, дата, название', () => { + const sheetOrder = ['OPER', 'AHR']; + const shuffled = [stages[4], stages[1], stages[3], stages[0], stages[2]]; + const res = sortStages(shuffled, sheetOrder); + assert.deepEqual( + res.map((s) => `${s.sheet}|${s.direction ?? ''}|${s.phase_code}`), + ['OPER||Ёлка', 'OPER||Сбор', 'AHR|Support|Сбор', 'AHR|Support|Будущее', 'AHR|Development|Сбор'], + ); +}); + +test('sortStages не мутирует вход, неизвестный лист в конце', () => { + const input = [mk('ZZZ', null, 'A'), mk('AHR', null, 'B')]; + const res = sortStages(input, ['AHR']); + assert.equal(res[0].sheet, 'AHR'); + assert.equal(input[0].sheet, 'ZZZ'); +}); -- 2.47.2 From 59f9ebdafe1783081d03f1b87160680b9c496c46 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 17:10:17 +0400 Subject: [PATCH 7/8] =?UTF-8?q?feat(stages):=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D1=8B=20=D0=B8=20=D1=81=D0=BE=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=B5=20=C2=AB=D0=AD=D1=82=D0=B0=D0=BF=D1=8B=C2=BB=20(AURORA-1?= =?UTF-8?q?881)?= 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/SettingModal.jsx | 46 +++++++++++++-- web/src/components/Stages/StagesFilters.jsx | 56 +++++++++++++++++++ .../Stages/StagesTable/StagesTable.jsx | 4 +- web/src/components/Stages/stageFilters.js | 6 +- 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 web/src/components/Stages/StagesFilters.jsx diff --git a/web/src/components/Stages/SettingModal.jsx b/web/src/components/Stages/SettingModal.jsx index ad213bd..1f7d058 100644 --- a/web/src/components/Stages/SettingModal.jsx +++ b/web/src/components/Stages/SettingModal.jsx @@ -1,5 +1,5 @@ import { Box, CircularProgress, Stack } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useAuth } from '../../app/context/AuthProvider'; import { useTaskStages } from '../../hooks/useTaskStages'; import { useProjectStages } from '../../hooks/useProjectStages'; @@ -10,7 +10,9 @@ import { EditContainer } from '../common/Modal/ModalStyled'; import { WhitePlus } from '../common/icons/icons'; import { AddStagesModal } from './AddStagesModal'; import { DeleteStageModal } from './DeleteStageModal'; +import { StagesFilters } from './StagesFilters'; import { StagesTable } from './StagesTable/StagesTable'; +import { EMPTY_STAGE_FILTERS, filterStages, sortStages } from './stageFilters'; export const SettingModal = ({ isOpen = true, @@ -50,6 +52,24 @@ export const SettingModal = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [stageToDelete, setStageToDelete] = useState(null); const [sheetOptions, setSheetOptions] = useState([]); + const [filters, setFilters] = useState(EMPTY_STAGE_FILTERS); + + useEffect(() => { + if (!isOpen) setFilters(EMPTY_STAGE_FILTERS); + }, [isOpen]); + + const allStages = stagesInfo.stages || []; + const visibleStages = useMemo( + () => + filterStages( + sortStages( + allStages, + (sheets || []).map((s) => s.sheet), + ), + filters, + ), + [allStages, sheets, filters], + ); const handleAddStage = () => { setStageForEdit(null); @@ -119,11 +139,25 @@ export const SettingModal = ({ ) : ( - + <> + + 0 && visibleStages.length === 0 + ? 'По выбранным фильтрам этапы не найдены' + : undefined + } + onEdit={handleEditStage} + onDelete={handleDeleteStage} + /> + )} {isAdmin && ( { + const title = SHEET_NAME[sheet] || name || sheet; + return direction ? `${title} — ${DIRECTION_TRANSLATE[direction] ?? direction}` : title; +}; + +export const StagesFilters = ({ filters, onChange, sheets = [], showSheet = true, showRole = false }) => { + const changeFilter = (key, value) => onChange({ ...filters, [key]: value }); + const sheetOptions = []; + for (const item of sheets) { + const value = getSheetKey(item); + if (!sheetOptions.some((o) => o.value === value)) sheetOptions.push({ value, label: getSheetLabel(item) }); + } + + const selects = [ + showSheet && ['sheetKey', 'Лист', sheetOptions], + showRole && ['role', 'Роль', Object.entries(STAGE_ROLE_RUSSIAN_NAME).map(([value, label]) => ({ value, label }))], + ['status', 'Статус', STATUS_OPTIONS.map((value) => ({ value, label: value }))], + ].filter(Boolean); + + return ( + + changeFilter('search', e.target.value)} + sx={{ minWidth: 220, flex: 1 }} + /> + {selects.map(([key, label, options]) => ( + changeFilter(key, e.target.value)} + sx={{ minWidth: 170 }}> + Все + {options.map(({ value, label: text }) => ( + + {text} + + ))} + + ))} + onChange({ ...EMPTY_STAGE_FILTERS })}>Сбросить + + ); +}; diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx index 04bd411..1e68c5d 100644 --- a/web/src/components/Stages/StagesTable/StagesTable.jsx +++ b/web/src/components/Stages/StagesTable/StagesTable.jsx @@ -9,7 +9,7 @@ import { getStageKey } from '../utils'; import { StyledTable, StyledTableContainer, StyledTableHead, StyledTableHeadRow, StyledTableRow } from './StagesTable.style'; import { getStageStatus, getStatusColors } from './utils'; -export const StagesTable = ({ stages, onEdit, onDelete }) => { +export const StagesTable = ({ stages, onEdit, onDelete, emptyText = 'Нет данных для отображения' }) => { const [anchorEl, setAnchorEl] = useState(null); const [selectedStage, setSelectedStage] = useState(null); const { user } = useAuth(); @@ -57,7 +57,7 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => { {!stages || stages.length === 0 ? ( - Нет данных для отображения + {emptyText} ) : ( diff --git a/web/src/components/Stages/stageFilters.js b/web/src/components/Stages/stageFilters.js index 4fdb979..823df24 100644 --- a/web/src/components/Stages/stageFilters.js +++ b/web/src/components/Stages/stageFilters.js @@ -6,7 +6,11 @@ export const DIRECTION_ORDER = ['Support', 'Development']; export const getSheetKey = ({ sheet, direction }) => `${sheet}|${direction ?? ''}`; -const normalize = (value) => String(value ?? '').trim().toLowerCase().replaceAll('ё', 'е'); +const normalize = (value) => + String(value ?? '') + .trim() + .toLowerCase() + .replaceAll('ё', 'е'); export const filterStages = (stages, filters = EMPTY_STAGE_FILTERS, now = new Date()) => { const { search = '', sheetKey = '', role = '', status = '' } = filters ?? {}; -- 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 8/8] =?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