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),