AURORA-1879: одноимённые этапы «Поддержки» и «Развития» — раздельно в списке, правке, удалении и аудите #315

Open
aep wants to merge 5 commits from feat/312-aurora-1879-form-phase-direction into test
9 changed files with 556 additions and 2 deletions

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0060"
down_revision = "0059"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0060_form_phase_direction_fix.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,339 @@
-- AURORA-1879: одноимённые этапы Support/Development.
-- 1. Чистка дублей этапов без направления и частичный уникальный индекс.
-- 2. direction в идентификаторе этапа в аудите (add/upd/del), direction в payload upd.
-- 3. del_form_phase удаляет только этап с указанным phase_code.
DO $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
$do$;
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$
;

View File

@ -22,6 +22,6 @@ class FormPhase(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True) phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) 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)) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -29,6 +29,12 @@ class FormPhaseRepository:
query = query.where(FormPhase.phase_code == phase_code) query = query.where(FormPhase.phase_code == phase_code)
if role is not None: if role is not None:
query = query.where(FormPhase.role == role) 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() return (await self.db.execute(query)).scalars().all()
async def get( async def get(

View File

@ -106,10 +106,21 @@ class FormPhaseService:
form_phase=body, form_phase=body,
budget_form=budget_form, 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) opens_at = to_org_local_naive(body.opens_at, utc_offset)
closes_at = to_org_local_naive(body.closes_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( 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, sheet=body.sheet,
direction=body.direction, direction=body.direction,
phase_code=body.phase_code, phase_code=body.phase_code,

View File

@ -421,3 +421,141 @@ def test_form_phases_create_aware_dates_keep_instant(client, admin_tokens):
assert _instant(patched["opens_at"]) == _instant(body["opens_at"]) assert _instant(patched["opens_at"]) == _instant(body["opens_at"])
finally: finally:
client.delete("/api/v1/stages/form/4/AHR/tz_aware_phase", headers=headers) 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
# ?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")}
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
# После ошибки клиентская сессия откатывается целиком (tests/integration/conftest.py),
# поэтому число строк здесь не проверяем — защиту БД проверяет следующий тест.
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):
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

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

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, phase_code=None,
role=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(): def _form_service():
service = FormPhaseService(db=AsyncMock()) service = FormPhaseService(db=AsyncMock())
service.fp_repo = AsyncMock() service.fp_repo = AsyncMock()
service.fp_repo.get.return_value = None # этапа с таким именем ещё нет
form = SimpleNamespace( form = SimpleNamespace(
id=1, id=1,
form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None), form_type=SimpleNamespace(section_list=["plan"], sheet_list_with_directions=None),