fix(stages): FormPhaseService.create отклоняет дубль этапа на листе и направлении (AURORA-1879)

Интеграционные тесты одноимённых этапов, PATCH/DELETE одного этапа, дубль без направления, direction в аудите.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-11 16:39:24 +04:00
parent 9e15fad226
commit 92f6049a73
4 changed files with 165 additions and 1 deletions

View File

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

View File

@ -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"

View File

@ -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()

View File

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