AURORA-1880: окно «Этапы» — этап с направлением в строке, правке и удалении (поверх #315) #317
15
api/alembic/versions/0060_form_phase_direction_fix.py
Normal file
15
api/alembic/versions/0060_form_phase_direction_fix.py
Normal 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
|
||||
339
api/alembic/versions/sql/0060_form_phase_direction_fix.sql
Normal file
339
api/alembic/versions/sql/0060_form_phase_direction_fix.sql
Normal 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$
|
||||
;
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"])
|
||||
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
|
||||
|
||||
# ?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"
|
||||
|
||||
11
api/tests/unit/test_form_phase_model.py
Normal file
11
api/tests/unit/test_form_phase_model.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
4
web/src/api/stagesRequest.js
Normal file
4
web/src/api/stagesRequest.js
Normal file
@ -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 } : {},
|
||||
});
|
||||
16
web/src/api/stagesRequest.test.js
Normal file
16
web/src/api/stagesRequest.test.js
Normal file
@ -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, {});
|
||||
});
|
||||
@ -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 }) => {
|
||||
<Stack>
|
||||
<Typography sx={{ color: 'var(--GreyDark, rgba(74, 85, 101, 1))' }}>Удалить этот элемент?</Typography>
|
||||
|
||||
{stage && (
|
||||
<Typography sx={{ marginTop: '0.5rem', fontWeight: 500 }}>
|
||||
{SHEET_NAME[stage.sheet] ?? stage.sheet}
|
||||
{stage.direction ? ` (${DIRECTION_TRANSLATE[stage.direction] ?? stage.direction})` : ''} — {stage.phase_code}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant='grey' sx={{ marginTop: '0.5rem' }}>
|
||||
Он будет удалён без возможности восстановления.
|
||||
</Typography>
|
||||
|
||||
@ -122,7 +122,7 @@ export const SettingModal = ({
|
||||
<StagesTable
|
||||
stages={stagesInfo.stages || []}
|
||||
onEdit={handleEditStage}
|
||||
onDelete={!isTaskMode ? handleDeleteStage : undefined}
|
||||
onDelete={handleDeleteStage}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@ -141,7 +141,7 @@ export const SettingModal = ({
|
||||
defaultSheet={mode === 'project' ? reportType : ''}
|
||||
/>
|
||||
)}
|
||||
{!isTaskMode && (
|
||||
{isAdmin && (
|
||||
<DeleteStageModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
|
||||
@ -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 }) => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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 (
|
||||
<StyledTableRow key={rowKey}>
|
||||
<StyledTableRow key={getStageKey(stage)}>
|
||||
<TableCell>
|
||||
<Typography variant='body2'>
|
||||
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
|
||||
{SHEET_NAME[stage.sheet]}
|
||||
{stage.direction ? ` (${DIRECTION_TRANSLATE[stage.direction] ?? stage.direction})` : ''} — {stage.phase_code}
|
||||
</Typography>
|
||||
{isAdmin && (
|
||||
<Typography variant='caption' color='text.secondary' sx={{ fontSize: '0.65rem' }}>
|
||||
|
||||
@ -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('|');
|
||||
|
||||
28
web/src/components/Stages/utils.test.js
Normal file
28
web/src/components/Stages/utils.test.js
Normal file
@ -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');
|
||||
});
|
||||
@ -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('Этап успешно удален');
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user