diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py
index f43048e..f6efd71 100644
--- a/api/src/api/v1/form_phases.py
+++ b/api/src/api/v1/form_phases.py
@@ -54,6 +54,10 @@ async def get_form_phases(
org_unit_service = OrgUnitService(db)
bf_service = BudgetFormService(db)
+ form = await bf_service.get(user=current_user, budget_form_id=form_id)
+ if not form:
+ raise HTTPException(404, "Форма не найдена")
+
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
@@ -61,22 +65,18 @@ async def get_form_phases(
direction=direction,
phase_code=phase_code,
)
- if phases:
- form = await bf_service.get(user=current_user, budget_form_id=form_id)
- org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
- result = [FormPhaseResponse.model_validate(p) for p in phases]
- if org_unit.utc_offset:
- tz = timezone_from_offset(org_unit.utc_offset)
- for element in result:
- if element.opens_at:
- element.opens_at = element.opens_at.astimezone(tz)
- if element.closes_at:
- element.closes_at = element.closes_at.astimezone(tz)
- else:
- result = []
+ org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
+ result = [FormPhaseResponse.model_validate(p) for p in phases]
+ if org_unit.utc_offset:
+ tz = timezone_from_offset(org_unit.utc_offset)
+ for element in result:
+ if element.opens_at:
+ element.opens_at = element.opens_at.astimezone(tz)
+ if element.closes_at:
+ element.closes_at = element.closes_at.astimezone(tz)
return BaseListResponse(
result=result,
- count=len(phases),
+ count=len(result),
)
@@ -194,6 +194,10 @@ async def get_form3_phases(
project_service = ProjectService(db)
org_unit_service = OrgUnitService(db)
+ project = await project_service.get_instance(user=current_user, project_id=project_id)
+ if not project:
+ raise HTTPException(404, "Проект не найден")
+
reports = await report_service.get_list(
project_id=project_id,
year=year,
@@ -203,21 +207,18 @@ async def get_form3_phases(
phases = await f3p_service.get_list(
report_id=[el.id for el in reports],
+ user=current_user,
phase_code=phase_code,
)
- if phases:
- project = await project_service.get_instance(user=current_user, project_id=project_id)
- org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id)
- result = [Form3PhaseResponse.model_validate(p) for p in phases]
- if org_unit.utc_offset:
- tz = timezone_from_offset(org_unit.utc_offset)
- for element in result:
- if element.opens_at:
- element.opens_at = element.opens_at.astimezone(tz)
- if element.closes_at:
- element.closes_at = element.closes_at.astimezone(tz)
- else:
- result = []
+ result = [Form3PhaseResponse.model_validate(p) for p in phases]
+ org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id)
+ if org_unit.utc_offset:
+ tz = timezone_from_offset(org_unit.utc_offset)
+ for element in result:
+ if element.opens_at:
+ element.opens_at = element.opens_at.astimezone(tz)
+ if element.closes_at:
+ element.closes_at = element.closes_at.astimezone(tz)
if result:
reports = {report.id: report for report in reports}
for i, element in enumerate(result):
@@ -225,7 +226,7 @@ async def get_form3_phases(
element.report_type = reports[phases[i].rf_project_report_id].report_type
return BaseListResponse(
result=result,
- count=len(phases),
+ count=len(result),
)
diff --git a/api/src/repository/form3_phase_repository.py b/api/src/repository/form3_phase_repository.py
index 39b1488..94e6a1b 100644
--- a/api/src/repository/form3_phase_repository.py
+++ b/api/src/repository/form3_phase_repository.py
@@ -14,6 +14,7 @@ class Form3PhaseRepository:
self,
report_id: int | list[int],
phase_code: str | None = None,
+ role: str | None = None,
) -> list[Form3Phase]:
if isinstance(report_id, int):
query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id)
@@ -21,6 +22,8 @@ class Form3PhaseRepository:
query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id))
if phase_code is not None:
query = query.where(Form3Phase.phase_code == phase_code)
+ if role is not None:
+ query = query.where(Form3Phase.role == role)
return (await self.db.execute(query)).scalars().all()
async def get(
diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py
index 38bf8a4..0c96c9f 100644
--- a/api/src/repository/form_phase_repository.py
+++ b/api/src/repository/form_phase_repository.py
@@ -16,6 +16,7 @@ class FormPhaseRepository:
sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None,
+ role: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
@@ -23,9 +24,11 @@ class FormPhaseRepository:
FormPhase.sheet == sheet,
FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction
)
-
+
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
+ if role is not None:
+ query = query.where(FormPhase.role == role)
return (await self.db.execute(query)).scalars().all()
async def get(
diff --git a/api/src/services/form3_phase_service.py b/api/src/services/form3_phase_service.py
index 969fd14..3ce924a 100644
--- a/api/src/services/form3_phase_service.py
+++ b/api/src/services/form3_phase_service.py
@@ -7,6 +7,7 @@ from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate
+from src.services.form_phase_service import role_code_for_user
class Form3PhaseService:
@@ -17,11 +18,13 @@ class Form3PhaseService:
async def get_list(
self,
report_id: int | list[int],
+ user: AppUser,
phase_code: str | None = None,
) -> list[Form3Phase]:
return await self.f3p_repo.get_list(
report_id=report_id,
phase_code=phase_code,
+ role=role_code_for_user(user),
)
async def get(
diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py
index 5ca14dc..5d3bcd9 100644
--- a/api/src/services/form_phase_service.py
+++ b/api/src/services/form_phase_service.py
@@ -10,6 +10,21 @@ from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
from src.repository.form_phase_repository import FormPhaseRepository
+# Код роли этапа (v3.role.code) по роли пользователя. Единственное место сопоставления,
+# используется и FormPhaseService, и Form3PhaseService. Для ADMIN фильтр не нужен — его в
+# словаре нет, role_code_for_user возвращает None.
+ROLE_CODE_BY_USER_ROLE: dict[UserRoleEnum, str] = {
+ UserRoleEnum.EXECUTOR_DFIP: "DFIP",
+ UserRoleEnum.EXECUTOR_RF: "EXECUTOR_RF",
+ UserRoleEnum.EXECUTOR_DURRS: "DURRS",
+}
+
+
+def role_code_for_user(user: AppUser) -> str | None:
+ """Код роли этапа для фильтрации по пользователю; None — без фильтра (ADMIN)."""
+ return ROLE_CODE_BY_USER_ROLE.get(user.role_id)
+
+
class FormPhaseService:
def __init__(self, db: AsyncSession):
self.db = db
@@ -29,6 +44,7 @@ class FormPhaseService:
sheet=sheet,
direction=direction,
phase_code=phase_code,
+ role=role_code_for_user(user),
)
async def get(
diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py
index 9a6938b..30b0377 100644
--- a/api/tests/integration/test_form_phases_api_smoke.py
+++ b/api/tests/integration/test_form_phases_api_smoke.py
@@ -326,3 +326,55 @@ def test_form3_phases_delete(client, admin_tokens):
)
assert response.status_code == 200
+
+def test_form_phases_role_filter_for_executor(client, admin_tokens, isp_tokens):
+ # isp1 (role_id=2=DFIP) видит на своей форме (budget_form 4, org_unit 2) только этапы роли DFIP.
+ response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens))
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["result"], "в фикстуре должен быть хотя бы один этап DFIP на форме 4"
+ assert all(phase["role"] == "DFIP" for phase in payload["result"])
+
+ # Админ в той же транзакции добавляет на этой же форме этап с ролью EXECUTOR_RF.
+ create_body = {
+ "sheet": "AHR",
+ "phase_code": "role_filter_check",
+ "role": "EXECUTOR_RF",
+ "column_keys": ["plan.q1"],
+ "opens_at": "2026-01-01T00:00:00",
+ "closes_at": "2026-12-31T00:00:00",
+ "direction": None,
+ }
+ create_response = client.post(
+ "/api/v1/stages/form/4", json=create_body, headers=_auth_headers(admin_tokens)
+ )
+ assert create_response.status_code == 200
+
+ # isp1 по-прежнему видит только роль DFIP, новый этап EXECUTOR_RF ему не виден.
+ isp_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens))
+ assert isp_response.status_code == 200
+ isp_payload = isp_response.json()
+ assert all(phase["role"] == "DFIP" for phase in isp_payload["result"])
+ assert not any(phase["phase_code"] == "role_filter_check" for phase in isp_payload["result"])
+
+ # Админ видит все этапы формы: список не урезан по роли.
+ admin_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(admin_tokens))
+ assert admin_response.status_code == 200
+ admin_payload = admin_response.json()
+ admin_roles = {phase["role"] for phase in admin_payload["result"]}
+ assert "DFIP" in admin_roles
+ assert "EXECUTOR_RF" in admin_roles
+ assert admin_payload["count"] == isp_payload["count"] + 1
+
+
+def test_form_phases_foreign_org_404(client, isp_tokens):
+ # budget_form 1 принадлежит org_unit 1, isp1 состоит только в org_unit 2.
+ response = client.get("/api/v1/stages/form/1", headers=_auth_headers(isp_tokens))
+ assert response.status_code == 404
+
+
+def test_form3_phases_foreign_org_404(client, isp_tokens):
+ # project 2 принадлежит org_unit 1, isp1 состоит только в org_unit 2.
+ response = client.get("/api/v1/stages/project/2", headers=_auth_headers(isp_tokens))
+ assert response.status_code == 404
+
diff --git a/api/tests/unit/test_form_phase_service.py b/api/tests/unit/test_form_phase_service.py
new file mode 100644
index 0000000..a89db12
--- /dev/null
+++ b/api/tests/unit/test_form_phase_service.py
@@ -0,0 +1,62 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from src.db.models.role import UserRoleEnum
+from src.services.form_phase_service import FormPhaseService
+
+
+@pytest.fixture
+def service_with_mock_repo(monkeypatch):
+ mock_repo = MagicMock()
+ mock_repo.get_list = AsyncMock(return_value=[])
+
+ repo_cls = MagicMock(return_value=mock_repo)
+ monkeypatch.setattr("src.services.form_phase_service.FormPhaseRepository", repo_cls)
+ monkeypatch.setattr(
+ "src.services.form_phase_service.BudgetFormRepository", MagicMock(return_value=MagicMock())
+ )
+
+ service = FormPhaseService(AsyncMock())
+ return service, mock_repo
+
+
+@pytest.fixture
+def executor_dfip_user():
+ return SimpleNamespace(id=2, role_id=UserRoleEnum.EXECUTOR_DFIP.value)
+
+
+@pytest.fixture
+def admin_user():
+ return SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN.value)
+
+
+@pytest.mark.asyncio
+async def test_get_list_filters_by_role_for_executor_dfip(service_with_mock_repo, executor_dfip_user):
+ service, mock_repo = service_with_mock_repo
+
+ await service.get_list(budget_form_id=4, user=executor_dfip_user)
+
+ mock_repo.get_list.assert_awaited_once_with(
+ budget_form_id=4,
+ sheet=None,
+ direction=None,
+ phase_code=None,
+ role="DFIP",
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_list_no_role_filter_for_admin(service_with_mock_repo, admin_user):
+ service, mock_repo = service_with_mock_repo
+
+ await service.get_list(budget_form_id=4, user=admin_user)
+
+ mock_repo.get_list.assert_awaited_once_with(
+ budget_form_id=4,
+ sheet=None,
+ direction=None,
+ phase_code=None,
+ role=None,
+ )
diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx
index d42c6ac..bac0e40 100644
--- a/web/src/components/Stages/StagesTable/StagesTable.jsx
+++ b/web/src/components/Stages/StagesTable/StagesTable.jsx
@@ -70,9 +70,11 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
-
- {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
-
+ {isAdmin && (
+
+ {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
+
+ )}
{formatDate(stage.opens_at)}