Merge pull request 'AURORA-1883: справочник ВСП — просмотр закрытых ВСП (статус, период закрытия), подпись закрытых ВСП в формах (поверх #328)' (#332) from feat/330-aurora-1883-closed-vsp into test

This commit is contained in:
aep 2026-11-12 17:04:46 +03:00
commit b003eda7c5
24 changed files with 1060 additions and 88 deletions

View File

@ -13,9 +13,10 @@ from src.domain.schemas import (
VSPCreate, VSPCreate,
VSPExportRequest, VSPExportRequest,
VSPInDB, VSPInDB,
VSPStatus,
VSPUpdate, VSPUpdate,
) )
from src.services.vsp_service import VSPService from src.services.vsp_service import VSPService, vsp_status_to_is_active
router = APIRouter(prefix="/dict/info", tags=["info"]) router = APIRouter(prefix="/dict/info", tags=["info"])
@ -30,6 +31,9 @@ async def get_vsp(
placement_type: str | None = None, placement_type: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
status: VSPStatus = "active",
close_date_start: date | None = None,
close_date_end: date | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor), current_user: AppUser = Depends(require_executor),
): ):
@ -46,7 +50,9 @@ async def get_vsp(
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
load_org_unit=True, load_org_unit=True,
is_active=True, is_active=vsp_status_to_is_active(status),
close_date_start=close_date_start,
close_date_end=close_date_end,
with_count=True, with_count=True,
) )
return BaseListResponse( return BaseListResponse(
@ -61,12 +67,15 @@ async def get_vsp(
async def get_vsp_dropdown( async def get_vsp_dropdown(
ssp_id: int | None = None, ssp_id: int | None = None,
form_id: int | None = None, form_id: int | None = None,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor), current_user: AppUser = Depends(require_executor),
): ):
"""Получение записей для выпадающего списка INFO.""" """Получение записей для выпадающего списка INFO."""
service = VSPService(db) service = VSPService(db)
result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True) result = await service.get_dropdown(
user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True, include_inactive=include_inactive,
)
return BaseListResponse( return BaseListResponse(
success=True, success=True,
message="Список INFO для выпадающего списка", message="Список INFO для выпадающего списка",
@ -93,6 +102,9 @@ async def export_info(
placement_type=body.placement_type, placement_type=body.placement_type,
staff_count_min=body.staff_count_min, staff_count_min=body.staff_count_min,
staff_count_max=body.staff_count_max, staff_count_max=body.staff_count_max,
status=body.status,
close_date_start=body.close_date_start,
close_date_end=body.close_date_end,
) )
return StreamingResponse( return StreamingResponse(
stream, stream,

View File

@ -760,6 +760,10 @@ class VSPUpdate(VSPEditBase):
) )
# AURORA-1883: статус справочника ВСП — действующие / закрытые / все.
VSPStatus = Literal["active", "closed", "all"]
class VSPInDB(VSPBase): class VSPInDB(VSPBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@ -795,6 +799,9 @@ class VSPExportRequest(BaseModel):
placement_type: Optional[str] = None placement_type: Optional[str] = None
staff_count_min: Optional[int] = None staff_count_min: Optional[int] = None
staff_count_max: Optional[int] = None staff_count_max: Optional[int] = None
status: VSPStatus = "active"
close_date_start: Optional[date] = None
close_date_end: Optional[date] = None
class ExpenseItemBaseSchema(BaseModel): class ExpenseItemBaseSchema(BaseModel):

View File

@ -59,7 +59,8 @@ class VSPRepository:
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
is_active: bool | None = True, is_active: bool | None = True,
close_date_is_null: bool | None = None, close_date_start: date | None = None,
close_date_end: date | None = None,
load_org_unit: bool = False, load_org_unit: bool = False,
with_count: bool = False, with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]: ) -> list[Vsp] | tuple[int, list[Vsp]]:
@ -78,6 +79,8 @@ class VSPRepository:
("staff_count_min", staff_count_min), ("staff_count_min", staff_count_min),
("staff_count_max", staff_count_max), ("staff_count_max", staff_count_max),
("is_active", is_active), ("is_active", is_active),
("close_date_start", close_date_start),
("close_date_end", close_date_end),
] ]
for filter_name, filter_value in filters: for filter_name, filter_value in filters:
# print(filter_name, filter_value) # print(filter_name, filter_value)
@ -98,14 +101,13 @@ class VSPRepository:
queries.append(Vsp.staff_count <= value) queries.append(Vsp.staff_count <= value)
case ("is_active", value) if value is not None: case ("is_active", value) if value is not None:
queries.append(Vsp.is_active == value) queries.append(Vsp.is_active == value)
case ("close_date_start", value) if value is not None:
queries.append(Vsp.closed_at >= value)
case ("close_date_end", value) if value is not None:
queries.append(Vsp.closed_at <= value)
case _: case _:
continue continue
if close_date_is_null is True:
queries.append(Vsp.closed_at.is_(None))
elif close_date_is_null is False:
queries.append(Vsp.closed_at.is_not(None))
if ssp_ids is not None: if ssp_ids is not None:
if not ssp_ids: if not ssp_ids:
if with_count: if with_count:
@ -116,7 +118,11 @@ class VSPRepository:
query = query.where(*queries) query = query.where(*queries)
if load_org_unit: if load_org_unit:
query = query.options(joinedload(Vsp.org_unit)) query = query.options(joinedload(Vsp.org_unit))
query = query.order_by(Vsp.id) if is_active is False:
# AURORA-1883: закрытые — свежезакрытые сверху
query = query.order_by(Vsp.closed_at.desc().nulls_last(), Vsp.id)
else:
query = query.order_by(Vsp.id)
if with_count: if with_count:
result = (await self.db.execute(query)).all() result = (await self.db.execute(query)).all()
@ -131,7 +137,6 @@ class VSPRepository:
return (await self.db.execute(count_query)).scalar(), [] return (await self.db.execute(count_query)).scalar(), []
else: else:
return (await self.db.execute(query)).scalars().all() return (await self.db.execute(query)).scalars().all()
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp: async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp:
data = payload.model_dump() data = payload.model_dump()

View File

@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.budget_form_repository import BudgetFormRepository from src.repository.budget_form_repository import BudgetFormRepository
from src.core.errors import AccessDeniedException, ValidationException from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate from src.domain.schemas import VSPCreate, VSPStatus, VSPUpdate
from src.repository.org_unit_repository import OrgUnitRepository from src.repository.org_unit_repository import OrgUnitRepository
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
from src.repository.vsp_repository import VSPRepository from src.repository.vsp_repository import VSPRepository
@ -18,6 +18,13 @@ from src.repository.vsp_repository import VSPRepository
_last_info_close_sync_run: Optional[date] = None _last_info_close_sync_run: Optional[date] = None
_info_close_sync_lock = asyncio.Lock() _info_close_sync_lock = asyncio.Lock()
_STATUS_TO_IS_ACTIVE: dict[str, bool | None] = {"active": True, "closed": False, "all": None}
def vsp_status_to_is_active(status: VSPStatus) -> bool | None:
"""AURORA-1883: статус справочника → фильтр is_active (None — без фильтра)."""
return _STATUS_TO_IS_ACTIVE[status]
class VSPService: class VSPService:
EXECUTOR_EDITABLE_FIELDS = { EXECUTOR_EDITABLE_FIELDS = {
@ -64,6 +71,8 @@ class VSPService:
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
is_active: bool | None = None, is_active: bool | None = None,
close_date_start: date | None = None,
close_date_end: date | None = None,
load_org_unit: bool = False, load_org_unit: bool = False,
with_count: bool = False, with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]: ) -> list[Vsp] | tuple[int, list[Vsp]]:
@ -94,6 +103,8 @@ class VSPService:
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
is_active=is_active, is_active=is_active,
close_date_start=close_date_start,
close_date_end=close_date_end,
load_org_unit=load_org_unit, load_org_unit=load_org_unit,
with_count=with_count, with_count=with_count,
) )
@ -104,6 +115,7 @@ class VSPService:
ssp_id: int | None = None, ssp_id: int | None = None,
form_id: int | None = None, form_id: int | None = None,
with_count: bool = False, with_count: bool = False,
include_inactive: bool = False,
) -> list[Vsp]: ) -> list[Vsp]:
ssp_ids = await self._get_scoped_ssp_ids(user=user) ssp_ids = await self._get_scoped_ssp_ids(user=user)
@ -125,9 +137,10 @@ class VSPService:
ssp_ids = list(checking_ssps & set(ssp_ids)) ssp_ids = list(checking_ssps & set(ssp_ids))
await self._sync_closed_records() await self._sync_closed_records()
# AURORA-1883: include_inactive — с закрытыми, для подписей ВСП в листах форм
return await self.vsp_repo.get_list( return await self.vsp_repo.get_list(
ssp_ids=ssp_ids, ssp_ids=ssp_ids,
is_active=True, is_active=None if include_inactive else True,
with_count=with_count, with_count=with_count,
) )
@ -166,6 +179,9 @@ class VSPService:
placement_type: str | None = None, placement_type: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
status: VSPStatus = "active",
close_date_start: date | None = None,
close_date_end: date | None = None,
) -> tuple[io.BytesIO, str]: ) -> tuple[io.BytesIO, str]:
data = await self.get_list( data = await self.get_list(
user=user, user=user,
@ -177,7 +193,9 @@ class VSPService:
placement_type=placement_type, placement_type=placement_type,
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
is_active=True, is_active=vsp_status_to_is_active(status),
close_date_start=close_date_start,
close_date_end=close_date_end,
load_org_unit=True, load_org_unit=True,
) )

View File

@ -432,3 +432,144 @@ def test_vsp_clear_close_date_is_audited(client, admin_tokens, auth_headers, db_
changes = client.portal.call(_last_changes) changes = client.portal.call(_last_changes)
assert changes["closed_at"]["before"] is not None assert changes["closed_at"]["before"] is not None
assert changes["closed_at"]["after"] is None assert changes["closed_at"]["after"] is None
# --- AURORA-1883: статус и период закрытия в справочнике ---
def _create_named_vsp(client, admin_tokens, auth_headers, **extra) -> str:
reg_number = _unique_reg_number()
created = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "AURORA-1883",
"ssp_id": 1,
"open_date": "2020-01-01",
**extra,
},
headers=auth_headers(admin_tokens),
)
assert created.status_code == 201, created.text
return reg_number
def _list_reg_numbers(client, tokens, auth_headers, query: str = "") -> list[str]:
response = client.get(f"/api/v1/dict/info{query}", headers=auth_headers(tokens))
assert response.status_code == 200, response.text
return [item["registration_number"] for item in response.json()["result"]]
def test_vsp_list_status_default_active(client, admin_tokens, auth_headers):
active = _create_named_vsp(client, admin_tokens, auth_headers)
closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
regs = _list_reg_numbers(client, admin_tokens, auth_headers)
assert active in regs
assert closed not in regs
def test_vsp_list_status_closed(client, admin_tokens, auth_headers):
active = _create_named_vsp(client, admin_tokens, auth_headers)
closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
response = client.get("/api/v1/dict/info?status=closed", headers=auth_headers(admin_tokens))
assert response.status_code == 200
by_reg = {item["registration_number"]: item for item in response.json()["result"]}
assert closed in by_reg
assert by_reg[closed]["is_active"] is False
assert active not in by_reg
def test_vsp_list_status_all(client, admin_tokens, auth_headers):
active = _create_named_vsp(client, admin_tokens, auth_headers)
closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
regs = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=all")
assert active in regs
assert closed in regs
def test_vsp_list_status_invalid(client, admin_tokens, auth_headers):
response = client.get("/api/v1/dict/info?status=bogus", headers=auth_headers(admin_tokens))
assert response.status_code == 422
def test_vsp_list_deleted_never_returned(client, admin_tokens, auth_headers):
reg = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
listing = client.get("/api/v1/dict/info?status=all", headers=auth_headers(admin_tokens))
vsp_id = next(i["id"] for i in listing.json()["result"] if i["registration_number"] == reg)
deleted = client.delete(f"/api/v1/dict/info/{vsp_id}", headers=auth_headers(admin_tokens))
assert deleted.status_code == 200
for status in ("active", "closed", "all"):
assert reg not in _list_reg_numbers(client, admin_tokens, auth_headers, f"?status={status}")
def test_vsp_list_close_period(client, admin_tokens, auth_headers):
closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
inside = _list_reg_numbers(
client, admin_tokens, auth_headers,
"?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31",
)
assert closed in inside
outside = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=closed&close_date_start=2022-01-01")
assert closed not in outside
def test_vsp_list_closed_order(client, admin_tokens, auth_headers):
older = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
newer = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2024-06-01")
regs = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=closed")
assert regs.index(newer) < regs.index(older)
def test_vsp_list_closed_executor_scope(client, admin_tokens, isp_tokens, auth_headers):
# isp1 привязан к оргединице 2 (fixture.sql, user_org), оргединица 1 ему чужая
own = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=2, close_date="2021-03-15")
foreign = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=1, close_date="2021-03-15")
for status in ("closed", "all"):
regs = _list_reg_numbers(client, isp_tokens, auth_headers, f"?status={status}")
assert own in regs
assert foreign not in regs
def test_vsp_dropdown_include_inactive(client, admin_tokens, auth_headers):
closed = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=2, close_date="2021-03-15")
with_closed = client.get(
"/api/v1/dict/info/dropdown?form_id=2&include_inactive=true", headers=auth_headers(admin_tokens)
)
assert with_closed.status_code == 200
by_reg = {item["registration_number"]: item for item in with_closed.json()["result"]}
assert closed in by_reg
assert by_reg[closed]["is_active"] is False
default = client.get("/api/v1/dict/info/dropdown?form_id=2", headers=auth_headers(admin_tokens))
assert closed not in [item["registration_number"] for item in default.json()["result"]]
def _export_values(client, tokens, auth_headers, body: dict) -> set:
import io
from openpyxl import load_workbook
response = client.post("/api/v1/dict/info/export", json=body, headers=auth_headers(tokens))
assert response.status_code == 200, response.text
sheet = load_workbook(io.BytesIO(response.content)).active
return {cell for row in sheet.iter_rows(values_only=True) for cell in row}
def test_vsp_export_status_closed(client, admin_tokens, auth_headers):
active = _create_named_vsp(client, admin_tokens, auth_headers)
closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15")
closed_values = _export_values(client, admin_tokens, auth_headers, {"ssp_ids": [1], "status": "closed"})
assert closed in closed_values
assert active not in closed_values
default_values = _export_values(client, admin_tokens, auth_headers, {"ssp_ids": [1]})
assert active in default_values
assert closed not in default_values

View File

@ -0,0 +1,58 @@
import pytest
from src.services.vsp_service import vsp_status_to_is_active
@pytest.mark.parametrize("status, expected", [("active", True), ("closed", False), ("all", None)])
def test_vsp_status_to_is_active(status, expected):
assert vsp_status_to_is_active(status) is expected
# --- выгрузка и dropdown (AURORA-1883, задача 2) ---
from datetime import date
from unittest.mock import AsyncMock, MagicMock
from src.db.models import UserRoleEnum
from src.services.vsp_service import VSPService
def _admin():
user = MagicMock()
user.id = 1
user.role_id = UserRoleEnum.ADMIN
return user
def _dropdown_service() -> VSPService:
service = VSPService.__new__(VSPService)
service.vsp_repo = MagicMock()
service.vsp_repo.get_list = AsyncMock(return_value=(0, []))
service.form_repo = MagicMock()
service.form_repo.get = AsyncMock(return_value=None)
service._get_scoped_ssp_ids = AsyncMock(return_value=None)
service._sync_closed_records = AsyncMock()
return service
async def test_get_dropdown_include_inactive_passes_none():
service = _dropdown_service()
await service.get_dropdown(user=_admin(), ssp_id=2, include_inactive=True, with_count=True)
assert service.vsp_repo.get_list.await_args.kwargs["is_active"] is None
async def test_get_dropdown_default_active_only():
service = _dropdown_service()
await service.get_dropdown(user=_admin(), ssp_id=2, with_count=True)
assert service.vsp_repo.get_list.await_args.kwargs["is_active"] is True
async def test_export_xlsx_status_closed():
service = VSPService.__new__(VSPService)
service.get_list = AsyncMock(return_value=[])
await service.export_xlsx(user=_admin(), status="closed", close_date_start=date(2021, 1, 1))
kwargs = service.get_list.await_args.kwargs
assert kwargs["is_active"] is False
assert kwargs["close_date_start"] == date(2021, 1, 1)
assert kwargs["close_date_end"] is None

View File

@ -0,0 +1,195 @@
# AURORA-1883: просмотр закрытых ВСП — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** В справочнике ВСП можно смотреть закрытые ВСП (статус «Действующие / Закрытые / Все», период закрытия, бейдж статуса, выгрузка по фильтру), а в листах формы 2 закрытый ВСП подписан рег. номером с пометкой «(закрыт)», а не числом.
**Architecture:** Бэк: параметр `status` (и период закрытия) в роутере `/dict/info` и в теле выгрузки переводится чистой функцией в `is_active` и доходит до `VSPRepository.get_list`; dropdown получает `include_inactive`. Фронт: чистые утилиты параметров списка и подписи ВСП, переключатель статуса на странице справочника, полный список ВСП (с закрытыми) в контексте `RealtimeTable` и выбор только действующих в редакторе ячейки. Миграции нет.
**Tech Stack:** FastAPI 0.111, SQLAlchemy 2 async, pydantic 2; React 18 (JSX), MUI, material-react-table, `node --test`, Biome.
**Spec:** `openspec/changes/aurora-1883-closed-vsp/` — `proposal.md`, `specs/vsp-directory/spec.md`, `design.md` (решения D1–D8). Исполнитель читает design.md целиком перед своей задачей.
## Global Constraints
- База — ветка `feat/330-aurora-1883-closed-vsp` (поверх AURORA-1885, PR #328); PR — `--base test`.
- Значения статуса — ровно `active`, `closed`, `all`; по умолчанию `active`; иное — 422.
- Параметры: `close_date_start`, `close_date_end` (`YYYY-MM-DD`), `include_inactive` (bool, по умолчанию `false`).
- Без новых параметров поведение API и UI прежнее: только действующие ВСП.
- Удалённые ВСП (`is_deleted`) не возвращаются ни из списка, ни из выгрузки, ни из dropdown.
- Подписи: бейджи «Действует» / «Закрыт»; ячейка формы — `<рег. номер> (закрыт)`, ненайденный — `ВСП #<id>`; кнопки статуса «Действующие» / «Закрытые» / «Все»; поля «Закрыт с» / «по».
- Цвет приглушённого текста закрытых — `#99a1af`; цвета бейджей — как «Активен»/«Архив» в `web/src/pages/AdminPanel/SppsPage/SspTable.jsx`.
- `@mui/x-date-pickers` не использовать (не объявлен в package.json).
- Коммиты на русском, с `(AURORA-1883, #330)` и строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Окружение
- Python: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest`; пакеты не ставить.
- Postgres `perf-pg` (`perf:perf@localhost:55432`); на каждый прогон с `--init_db` — свежая БД: `docker exec perf-pg psql -U perf -d perf -c "DROP DATABASE IF EXISTS verify_1883" -c "CREATE DATABASE verify_1883"`, затем `cd api && DATABASE_URL="postgresql+asyncpg://perf:perf@localhost:55432/verify_1883" <venv>/bin/pytest --init_db <пути>`.
- Эталон полного backend на ветке 1885: 906 passed / 10 известных падений (form3 add_project, satellite spod ×3, upd_form_cells_subtree ×3, 0037_guard, export_service_query_params ×2).
- Фронт: в worktree `web/node_modules` нет — сделать симлинк `ln -s /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules web/node_modules` (как в worktree aurora-1896; симлинк не коммитить). `npm install` не запускать, реестр npm не подменять.
- Фикстура `api/tests/sqls/fixture.sql`: ВСП 1 (branch 2) уже закрыт (`closed_at 2026-06-16`, `is_active=false`), ВСП 2 и 3 (branch 2) действующие. Тесты создают свои ВСП через `_create_vsp(...)` в `test_vsp.py` (уже есть, от 1885) с уникальным рег. номером и ищут их по рег. номеру — не завязываться на общее число строк.
## Review Focus
1. Ячейка ВСП с id, которого нет в загруженном списке (удалённый ВСП), при разрядности «тыс.»/«млн» — должна показывать `ВСП #<id>`, а не `0,1` (Task 3, тест `getVspLabel` + проверка, что Cell не применяет `displayFactor`).
2. Редактор ячейки строки, где уже стоит закрытый ВСП, — поле не пустое (подпись закрытого), но в списке выбора его нет (Task 3).
3. Переключение статуса при выбранном ССП и периоде — ССП сохраняется, период уходит в запрос только для «Закрытые»/«Все» (Task 4, тест `buildVspListParams`).
4. Исполнитель с `status=all` — только ВСП своих ССП (Task 1, интеграционный тест).
5. Выгрузка из режима «Закрытые» — файл содержит закрытые, а не действующие (Task 2, интеграционный тест; Task 4 — передача параметров из модалки).
---
### Task 1: Бэк — статус и период закрытия в списке справочника
**Files:**
- Modify: `api/src/domain/schemas.py` (рядом с `VSPExportRequest`, ~:741)
- Modify: `api/src/services/vsp_service.py` (модульная функция; `get_list` ~:54-99)
- Modify: `api/src/repository/vsp_repository.py` (`get_list` ~:51-134)
- Modify: `api/src/api/v1/vsp.py` (`get_vsp` ~:23-57)
- Test: `api/tests/integration/test_vsp.py`, `api/tests/unit/test_vsp_status.py` (новый)
**Interfaces:**
- Produces: `VSPStatus = Literal["active", "closed", "all"]` в `src.domain.schemas`; `vsp_status_to_is_active(status: VSPStatus) -> bool | None` в `src.services.vsp_service`; `VSPService.get_list(..., is_active, close_date_start: date | None = None, close_date_end: date | None = None, ...)`; `VSPRepository.get_list(..., is_active, close_date_start: date | None = None, close_date_end: date | None = None, ...)` — без параметра `close_date_is_null`.
- [ ] **Step 1: Unit-тест перевода статуса** — `api/tests/unit/test_vsp_status.py`:
```python
import pytest
from src.services.vsp_service import vsp_status_to_is_active
@pytest.mark.parametrize("status, expected", [("active", True), ("closed", False), ("all", None)])
def test_vsp_status_to_is_active(status, expected):
assert vsp_status_to_is_active(status) is expected
```
- [ ] **Step 2: Интеграционные тесты** в `test_vsp.py` (каждый создаёт свои ВСП через `_create_vsp`, закрытый — `close_date` в прошлом; ищет по `registration_number` в `result`):
- `test_vsp_list_status_default_active` — действующий A есть, закрытый B (`close_date="2021-03-15"`) нет;
- `test_vsp_list_status_closed` — `?status=closed`: B есть с `is_active is False`, A нет;
- `test_vsp_list_status_all` — A и B есть;
- `test_vsp_list_status_invalid` — `?status=bogus` → 422;
- `test_vsp_list_deleted_never_returned` — ВСП C закрыт и удалён (`DELETE`), нет ни в `active`, ни в `closed`, ни в `all`;
- `test_vsp_list_close_period` — B (2021-03-15): `?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31` → есть; `?status=closed&close_date_start=2022-01-01` → нет;
- `test_vsp_list_closed_order` — B (2021-03-15) и D (2024-06-01): в `?status=closed` индекс D меньше индекса B;
- `test_vsp_list_closed_executor_scope` — закрытый ВСП на ССП исполнителя (`isp_tokens`) виден ему в `?status=closed` и в `?status=all`, закрытый на чужом ССП — нет. ССП исполнителя и чужой ССП взять из фикстуры (`api/tests/sqls/fixture.sql`, привязки пользователя-исполнителя); если чужого активного ССП нет — тест `pytest.skip` с причиной.
- [ ] **Step 3: Прогнать — падают** (`ImportError` у unit, 200 вместо 422 и лишние/недостающие строки у интеграционных):
`<venv>/bin/pytest --init_db tests/unit/test_vsp_status.py tests/integration/test_vsp.py -q` (свежая БД).
- [ ] **Step 4: Реализация**
- `schemas.py`: `VSPStatus`.
- `vsp_service.py`: `vsp_status_to_is_active`; `get_list` принимает и передаёт `close_date_start`, `close_date_end`.
- `vsp_repository.py` `get_list`: даты в `match` (`Vsp.closed_at >= start`, `<= end`); сортировка по D3 (`is_active is False` → `Vsp.closed_at.desc().nulls_last(), Vsp.id`, иначе `Vsp.id`); удалить `close_date_is_null` и мёртвый `return` в конце.
- `vsp.py` `get_vsp`: `status: VSPStatus = "active"`, `close_date_start: date | None = None`, `close_date_end: date | None = None`; `is_active=vsp_status_to_is_active(status)`.
- [ ] **Step 5: Прогнать — зелёные** (та же команда, свежая БД). Ожидается: все тесты `test_vsp.py` и новый unit — passed.
- [ ] **Step 6: Коммит** — `feat(vsp): фильтр справочника по статусу и периоду закрытия (AURORA-1883, #330)`.
### Task 2: Бэк — выгрузка и dropdown с закрытыми
**Files:**
- Modify: `api/src/domain/schemas.py` (`VSPExportRequest`)
- Modify: `api/src/services/vsp_service.py` (`get_dropdown` ~:101-132, `export_xlsx` ~:158-182)
- Modify: `api/src/api/v1/vsp.py` (`get_vsp_dropdown`, `export_info`)
- Test: `api/tests/integration/test_vsp.py`, `api/tests/unit/test_vsp_status.py`
**Interfaces:**
- Consumes: `VSPStatus`, `vsp_status_to_is_active`, `VSPService.get_list(close_date_start, close_date_end)` из Task 1.
- Produces: `VSPExportRequest.status: VSPStatus = "active"`, `.close_date_start`, `.close_date_end: date | None = None`; `VSPService.get_dropdown(..., include_inactive: bool = False)`; `VSPService.export_xlsx(..., status: VSPStatus = "active", close_date_start=None, close_date_end=None)`; query `GET /dict/info/dropdown?include_inactive=true`.
- [ ] **Step 1: Unit-тесты** в `test_vsp_status.py` с моком `vsp_repo` (как `_service` в `test_vsp_service_close_date.py`; для `get_dropdown` замокать `_get_scoped_ssp_ids`, `form_repo.get`, `_sync_closed_records`):
- `test_get_dropdown_include_inactive_passes_none` — `include_inactive=True` → `vsp_repo.get_list` вызван с `is_active=None`;
- `test_get_dropdown_default_active_only` — без параметра → `is_active=True`;
- `test_export_xlsx_status_closed` — `export_xlsx(user, status="closed", close_date_start=date(2021,1,1))` → `get_list` вызван с `is_active=False`, `close_date_start=date(2021,1,1)` (замокать `get_list` у сервиса, вернуть `[]`).
- [ ] **Step 2: Интеграционные тесты** в `test_vsp.py`:
- `test_vsp_dropdown_include_inactive` — закрытый ВСП на ССП 2 (`_create_vsp(..., ssp_id=2, close_date="2021-03-15")`): `dropdown?form_id=2&include_inactive=true` содержит его (`is_active is False`); `dropdown?form_id=2` — нет. Существующий `test_vsp_dropdown_with_filter` (без параметра) должен остаться зелёным;
- `test_vsp_export_status_closed` — `POST /dict/info/export` `{"ssp_ids":[1], "status":"closed"}` → в xlsx (openpyxl, `load_workbook(io.BytesIO(response.content))`) есть рег. номер закрытого ВСП и нет действующего; тот же запрос без `status` — наоборот.
- [ ] **Step 3: Прогнать — падают.**
- [ ] **Step 4: Реализация** по D4, D5 design.md.
- [ ] **Step 5: Прогнать — зелёные**, затем **полный backend** на свежей БД: `<venv>/bin/pytest --init_db -q`. Ожидается: те же 10 известных падений, новых нет (passed ≥ 906 + новые).
- [ ] **Step 6: Коммит** — `feat(vsp): выгрузка по статусу и периоду, закрытые ВСП в dropdown по include_inactive (AURORA-1883, #330)`.
### Task 3: Фронт — подпись ВСП в листах форм
**Files:**
- Create: `web/src/components/RealtimeTable/utils/vspLabel.js`, `web/src/components/RealtimeTable/utils/vspLabel.test.js`
- Modify: `web/src/components/RealtimeTable/Cell/Cell/Cell.jsx` (~:165-184)
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx` (~:311 `getDropdownVsp`)
- Modify: `web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx`
- Modify (комментарий): `web/src/components/RealtimeTable/Modals/SelectVspModal.jsx`
**Interfaces:**
- Consumes: `GET /dict/info/dropdown?include_inactive=true` (Task 2), поля ВСП `id`, `registration_number`, `is_active`.
- Produces: `getVspLabel(value, vspOptions) -> string | null`; `selectableVspOptions(vspOptions) -> array` (только `is_active !== false`) — обе в `vspLabel.js`.
- [ ] **Step 1: Тест** `vspLabel.test.js` (`node:test`, `node:assert/strict`, стиль соседних `*.test.js`):
```js
const opts = [
{ id: 1, registration_number: '1234/567', is_active: true },
{ id: 2, registration_number: '7777/001', is_active: false },
];
// getVspLabel
assert.equal(getVspLabel(1, opts), '1234/567');
assert.equal(getVspLabel(2, opts), '7777/001 (закрыт)');
assert.equal(getVspLabel('1', opts), '1234/567');
assert.equal(getVspLabel(99, opts), 'ВСП #99');
assert.equal(getVspLabel(null, opts), null);
assert.equal(getVspLabel('', opts), null);
assert.equal(getVspLabel(5, undefined), 'ВСП #5');
// selectableVspOptions
assert.deepEqual(selectableVspOptions(opts).map((v) => v.id), [1]);
assert.deepEqual(selectableVspOptions(undefined), []);
```
- [ ] **Step 2: Прогнать — падает:** `cd web && node --test src/components/RealtimeTable/utils/vspLabel.test.js` → ошибка импорта.
- [ ] **Step 3: Реализация** `vspLabel.js`; `Cell.jsx` — для `isVspDropdown` вернуть `{ rawValue: value, displayValue: getVspLabel(value, vspOptions) ?? '', isNumeric: false, isNegative: false }` **до** числового форматирования (D7); `RealtimeTable.jsx` — `getDropdownVsp({ form_id: formId, include_inactive: true })`; `VspDropdownEditCell.jsx` — пункты списка из `selectableVspOptions(vspOptions)`, отображение текущего значения через `getVspLabel` (закрытый не выбирается, но подписан); `SelectVspModal.jsx` — только комментарий, что модалка сама грузит dropdown без `include_inactive` и поэтому получает только действующие (AURORA-1883).
- [ ] **Step 4: Прогнать** `node --test src/components/RealtimeTable/utils/*.test.js` — ожидается: всё зелёное, кроме известного падения `SMETA.test.js`, если он входит в набор (сравнить с прогоном до правок); `npx biome check` по изменённым файлам — без новых ошибок относительно исходных.
- [ ] **Step 5: Коммит** — `feat(forms): подпись закрытого ВСП «(закрыт)» в листах форм, выбор только действующих (AURORA-1883, #330)`.
### Task 4: Фронт — справочник ВСП: статус, период, бейдж, выгрузка
**Files:**
- Create: `web/src/pages/DictVspPage/utils/vspListParams.js`, `web/src/pages/DictVspPage/utils/vspListParams.test.js`
- Modify: `web/src/pages/DictVspPage/DictVspPage.jsx` (`loadData` ~:34-65, `handleEdit` ~:87-101)
- Modify: `web/src/pages/DictVspPage/components/DictVspFilters.jsx`
- Modify: `web/src/pages/DictVspPage/components/TableDictVsp.jsx` (колонки ~:54-236)
- Modify: `web/src/pages/DictVspPage/components/Modals/ModalExport.jsx` (~:45-57) и место, где страница её рендерит (`DictVspModals.jsx`)
**Interfaces:**
- Consumes: `GET /dict/info?status&close_date_start&close_date_end` (Task 1), `POST /dict/info/export` с теми же полями (Task 2).
- Produces: `buildVspListParams({ status, closeDateStart, closeDateEnd }) -> { status, close_date_start?, close_date_end? }`; `vspMatchesStatus(vsp, status) -> boolean`.
- [ ] **Step 1: Тест** `vspListParams.test.js` (запуск `node --test src/pages/DictVspPage/utils/*.test.js`, как `filterVspBySsp.test.js` рядом):
```js
assert.deepEqual(buildVspListParams({ status: 'active', closeDateStart: '2021-01-01', closeDateEnd: '' }), { status: 'active' });
assert.deepEqual(buildVspListParams({ status: 'closed', closeDateStart: '2021-01-01', closeDateEnd: '2021-12-31' }),
{ status: 'closed', close_date_start: '2021-01-01', close_date_end: '2021-12-31' });
assert.deepEqual(buildVspListParams({ status: 'all', closeDateStart: '', closeDateEnd: '2021-12-31' }),
{ status: 'all', close_date_end: '2021-12-31' });
assert.equal(vspMatchesStatus({ is_active: true }, 'active'), true);
assert.equal(vspMatchesStatus({ is_active: false }, 'active'), false);
assert.equal(vspMatchesStatus({ is_active: false }, 'closed'), true);
assert.equal(vspMatchesStatus({ is_active: true }, 'closed'), false);
assert.equal(vspMatchesStatus({ is_active: false }, 'all'), true);
```
- [ ] **Step 2: Прогнать — падает** (ошибка импорта).
- [ ] **Step 3: Реализация** по D6 design.md: утилиты; `DictVspFilters` — `ToggleButtonGroup` «Действующие»/«Закрытые»/«Все» и два `TextField type="date"` «Закрыт с»/«по» при `status !== 'active'`; `DictVspPage` — state `status='active'`, `closeDateStart=''`, `closeDateEnd=''`, перезагрузка только списка ВСП по их изменению, выбранный ССП сохраняется, `handleEdit` убирает строку при `!vspMatchesStatus(updated, status)`; `TableDictVsp` — колонка «Статус» после «Дата закрытия ВСП» с бейджем, текст строки закрытого `#99a1af`; `ModalExport` — тело `{ ssp_ids, ...buildVspListParams(...) }`.
- [ ] **Step 4: Прогнать** `node --test src/pages/DictVspPage/utils/*.test.js` (зелёные), `npx biome check` по изменённым файлам (без новых ошибок), `npm run build` (успешно).
- [ ] **Step 5: Ручная проверка** (если поднимается стенд: API на БД `verify_1883` + `npm run start`): режимы статуса, период 2021, бейдж, закрытие ВСП в режиме «Действующие» убирает строку, выгрузка из «Закрытые»; лист FORM_2 со строкой на закрытом ВСП — подпись «(закрыт)», в выборе его нет. Если стенд не поднимается — отметить в отчёте как не выполненную.
- [ ] **Step 6: Коммит** — `feat(vsp): справочник ВСП — статус «Действующие/Закрытые/Все», период закрытия, бейдж и выгрузка по фильтру (AURORA-1883, #330)`.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-12

View File

@ -0,0 +1,109 @@
# Design — AURORA-1883: просмотр закрытых ВСП
## Context
Исходный дизайн — описание Jira AURORA-1883 (28.10.2026). Он сверен с кодом ветки `feat/327-aurora-1885-vsp-get-and-close` (744b71f, поверх `test` ac68699, 12.11.2026). Описание и код совпадают; где фронт с 28.10 изменился, это отмечено ниже (D7, D8).
Ветка 1883 (`feat/330-aurora-1883-closed-vsp`) построена поверх AURORA-1885 (PR #328): после 1885 прошедшая дата закрытия сразу даёт `is_active=false`, поэтому «закрытый» = `is_active=false` без оговорок. AURORA-1886 (PR после #329, миграция 0062) правит `VSPService.update` и `VSPRepository.update` — с 1883 пересечений по коду нет (1883 трогает `get_list`, `get_dropdown`, `export_xlsx`, роутер и фронт). Миграции в 1883 нет.
Как сейчас (бэк):
- `api/src/api/v1/vsp.py` `get_vsp` — `service.get_list(..., is_active=True, with_count=True, load_org_unit=True)`, статус зашит. `get_vsp_dropdown(ssp_id, form_id)` → `service.get_dropdown`. `export_info(body: VSPExportRequest)` → `service.export_xlsx`.
- `api/src/domain/schemas.py` `VSPExportRequest` — `registration_number`, `address`, `ssp_ids`, `open_date_start/end`, `placement_type`, `staff_count_min/max`; статуса нет. `VSPInDB` уже отдаёт `is_active` и `close_date` (alias `closed_at`).
- `VSPService.get_list` — синхронизация закрытия (`_sync_closed_records`), пересечение ССП с ССП исполнителя, вызов `vsp_repo.get_list(... is_active=is_active ...)`. `get_dropdown` — ССП формы/`ssp_id` ∩ ССП исполнителя, `vsp_repo.get_list(ssp_ids, is_active=True, with_count)`. `export_xlsx` — `get_list(..., is_active=True, load_org_unit=True)`.
- `VSPRepository.get_list` — `is_deleted = false` всегда; фильтры через `match`; `is_active` (по умолчанию `True`); `close_date_is_null` — никем не передаётся; `order_by(Vsp.id)`; в конце мёртвый `return` после `if/else`.
Как сейчас (фронт):
- `web/src/api/dict-vsp.js` — `get(params)`, `getById`, `export`, `getDropdownVsp(params)`.
- `pages/DictVspPage/DictVspPage.jsx` — `loadData` зовёт `DictVspApi.get()` без параметров; `handleEdit` обновляет строку в списке.
- `components/DictVspFilters.jsx` — только Autocomplete ССП (`ssps`, `selectedSsp`, `onSspChange`); фильтр по ССП — на клиенте в `TableDictVsp.jsx`.
- `components/Modals/ModalExport.jsx` — `DictVspApi.export({ ssp_ids: selectedSsps })`.
- `pages/AdminPanel/SppsPage/SspTable.jsx:130-150` — образец бейджа статуса (синий «Активен» / серый «Архив», текст `#99a1af`).
- `components/RealtimeTable/RealtimeTable.jsx` ~:304-318 — `DictVspApi.getDropdownVsp({ form_id })` → `setVspOptions` (контекст `RealtimeVspOptionsContext`, `contexts/RealtimeContext.js`); ~:450 `vspRegistrationById` для поиска/фильтра по колонке ВСП.
- `Cell/Cell/Cell.jsx` ~:165-184 — `isNum` считается до проверки `isVspDropdown`; если ВСП найден в `vspOptions` — показывается `registration_number`, иначе — id, отформатированный как сумма с `displayFactor`.
- `Cell/EditCell/VspDropdownEditCell.jsx` — список выбора из `useVspOptions()`.
- `Modals/SelectVspModal.jsx` — **свой** запрос `getDropdownVsp({ form_id })` в собственный state (не контекст).
## Goals / Non-Goals
**Goals**
- Найти в справочнике закрытый (в том числе давно) ВСП по статусу и периоду закрытия; видеть статус строки.
- Выгрузка по выбранному статусу и периоду.
- Подпись закрытого ВСП в листах форм — рег. номер с пометкой, не число.
- Поведение по умолчанию (без параметров) — прежнее везде.
**Non-Goals**
- Снятие даты закрытия (AURORA-1886), удаление ВСП (AURORA-1884).
- Загрузка архива закрытых ВСП из внешнего источника.
- Серверная пагинация справочника, перенос фильтра ССП на сервер.
- Изменение прав: кто видит какие ВСП, кто их редактирует.
## Decisions
### D1. `status` — параметр роутера, в сервис идёт `is_active`
`get_vsp`: `status: Literal["active", "closed", "all"] = "active"` (FastAPI сам отвечает 422 на иное значение). Перевод — одна чистая функция `vsp_status_to_is_active(status) -> bool | None` (`active` → `True`, `closed` → `False`, `all` → `None`) в `api/src/services/vsp_service.py` (модульный уровень), чтобы её покрыть unit-тестом и переиспользовать в выгрузке. Сервис и репозиторий по-прежнему принимают `is_active`. `VSPStatus = Literal["active", "closed", "all"]` объявляется в `schemas.py` и используется и в роутере, и в `VSPExportRequest`.
### D2. Период закрытия — `close_date_start` / `close_date_end` до репозитория
Добавить в `get_vsp`, `VSPService.get_list`, `VSPService.export_xlsx`, `VSPRepository.get_list`: `close_date_start: date | None`, `close_date_end: date | None` → `Vsp.closed_at >= start`, `Vsp.closed_at <= end` (в тот же `match`). `NULL >= …` в SQL ложно — ВСП без даты закрытия при заданной границе не попадает, отдельного условия не нужно. Фильтр применяется при любом `status` (у действующего ВСП может быть будущая дата закрытия; UI показывает поля периода только при «Закрытые» и «Все», но API не запрещает).
### D3. Сортировка закрытых
`VSPRepository.get_list`: при `is_active is False` — `order_by(Vsp.closed_at.desc().nulls_last(), Vsp.id)`, иначе — `order_by(Vsp.id)`. `nulls_last` — страховка: закрытый ВСП без даты возможен только при ручной правке БД. Мёртвый `return` в конце метода удалить. Параметр `close_date_is_null` удалить (никто не передаёт — проверено grep'ом по `api/`).
### D4. Выгрузка
`VSPExportRequest`: `status: VSPStatus = "active"`, `close_date_start: date | None = None`, `close_date_end: date | None = None`. `export_info` передаёт их в `export_xlsx`, тот — `is_active=vsp_status_to_is_active(status)` и даты в `get_list` вместо зашитого `is_active=True`. Набор колонок файла не меняется; колонка `close_date` в выгрузке уже есть (`vsp_service.py` ~:198, :225).
### D5. Dropdown с закрытыми
`get_vsp_dropdown`: `include_inactive: bool = False` → `service.get_dropdown(..., include_inactive=...)` → `vsp_repo.get_list(ssp_ids, is_active=None if include_inactive else True, with_count)`. Удалённые отсекает `is_deleted = false` в репозитории, как раньше.
### D6. Фронт — справочник
- Чистая утилита `web/src/pages/DictVspPage/utils/vspListParams.js`: `buildVspListParams({ status, closeDateStart, closeDateEnd })` → объект query-параметров: `status` всегда; даты (`YYYY-MM-DD`) только при `status !== 'active'` и если заданы. Её же использует выгрузка. Плюс `vspMatchesStatus(vsp, status)` — для `handleEdit`: `active` → `vsp.is_active`, `closed` → `!vsp.is_active`, `all` → `true`. Покрыть `node --test` (`vspListParams.test.js` рядом).
- `DictVspFilters.jsx`: новые пропсы `status`, `onStatusChange`, `closeDateStart`, `closeDateEnd`, `onCloseDateChange`. Переключатель — MUI `ToggleButtonGroup` (exclusive) «Действующие» / «Закрытые» / «Все» рядом с ССП. Поля дат — `TextField type="date"` «Закрыт с» / «по» (как в остальном коде справочника; `@mui/x-date-pickers` не объявлен в package.json — не использовать), видимы при `status !== 'active'`.
- `DictVspPage.jsx`: state `status` (`'active'`), `closeDateStart`, `closeDateEnd` (`''`); `loadData` — `DictVspApi.get(buildVspListParams(...))`; перезагрузка в `useEffect` по этим трём значениям. Остальные запросы `loadData` (ССП и т.п.) не трогать — перезагружать только список ВСП, если `loadData` грузит несколько вещей, вынести загрузку ВСП в отдельную функцию. `handleEdit`: после ответа сервера, если `!vspMatchesStatus(updated, status)` — убрать строку из списка, иначе заменить как сейчас. Переключение статуса сохраняет выбранный ССП.
- `TableDictVsp.jsx`: колонка «Статус» (после «Дата закрытия ВСП»), бейдж «Действует» (синий, как «Активен» в `SspTable`) / «Закрыт» (серый, как «Архив»). Текст ячеек строки закрытого ВСП — `#99a1af` (через `muiTableBodyRowProps`/`muiTableBodyCellProps` по `row.original.is_active`).
- `ModalExport.jsx`: принимает `status`, `closeDateStart`, `closeDateEnd` пропсами от страницы и передаёт `{ ssp_ids, ...buildVspListParams(...) }` в `DictVspApi.export`.
### D7. Фронт — подпись в листах форм
- Чистая утилита `web/src/components/RealtimeTable/utils/vspLabel.js`: `getVspLabel(value, vspOptions)` → `null` для пустого значения (`null`/`undefined`/`''`); иначе ищет по `Number(value)`; действующий → `registration_number`; закрытый (`is_active === false`) → `` `${registration_number} (закрыт)` ``; не найден → `` `ВСП #${value}` ``. Тест `vspLabel.test.js`: действующий, закрытый, отсутствующий id, строковый id `"123"`, пустое значение.
- `Cell.jsx`: для `isVspDropdown` ветка идёт **до** числового форматирования: `display = getVspLabel(value, vspOptions) ?? ''`, `isNumeric = false`, без `displayFactor`. (Отличие от Jira: на 28.10 в Cell не было поиска по `vspOptions` вовсе — сейчас он есть, но срабатывает только для найденных; меняем порядок, чтобы ненайденный не форматировался как сумма.)
- `RealtimeTable.jsx`: `getDropdownVsp({ form_id: formId, include_inactive: true })` — в контексте полный список (действующие + закрытые). `vspRegistrationById` строится из того же списка — закрытые находятся поиском по рег. номеру без изменений кода.
- `VspDropdownEditCell.jsx`: в список выбора — только `vspOptions.filter((v) => v.is_active !== false)`. Если текущее значение ячейки — закрытый ВСП, оно не теряется: Select показывает подпись из `getVspLabel` для текущего значения (не пустое поле), но выбрать закрытый снова нельзя.
- `SelectVspModal.jsx` — **не меняется**: делает собственный запрос без `include_inactive` и уже получает только действующие (отличие от Jira, где предлагалось фильтровать).
### D8. Тесты
- Интеграционные (`api/tests/integration/test_vsp.py`, фикстуры `admin_tokens` / `isp_tokens`; ВСП с прошлой датой закрытия создаётся админом через `POST /dict/info`, после 1885 он сразу закрыт): `status` по умолчанию / `closed` / `all`; период внутри/вне; исполнитель с `status=closed` — только свои ССП (если в фикстуре нет второго ССП, доступного админу и недоступного исполнителю, — создать ВСП на ССП вне его привязок); `status=bogus` → 422; удалённый не возвращается ни при каком статусе; порядок `closed` — по дате закрытия убыв.; `dropdown?form_id=…&include_inactive=true` содержит закрытый ВСП ССП формы, без параметра — нет; `POST /dict/info/export` с `status=closed` — в xlsx (openpyxl) есть закрытый ВСП, без статуса — нет.
- Unit (`api/tests/unit/`): `vsp_status_to_is_active` (три значения); `VSPService.export_xlsx`/`get_dropdown` с моком репозитория — какой `is_active` уходит в `get_list`.
- Фронт: `node --test` для `vspLabel.test.js`, `vspListParams.test.js`; `npx biome check` по изменённым файлам без новых ошибок; `npm run build`.
## Risks / Trade-offs
- Справочник грузится целиком без пагинации; «Все» на большом справочнике тяжелее «Действующих». Объём справочника — тысячи строк, приемлемо; пагинация — вне задачи.
- `include_inactive=true` увеличивает список ВСП, который получает каждая открытая форма 2 (на закрытые ВСП её ССП). Ограничено ССП формы — несущественно.
- Исполнитель видит закрытые ВСП своих ССП — так же, как видел их до закрытия; новых данных ему не открывается.
## Оставлено исполнителю
- Точная вёрстка переключателя и полей дат в `DictVspFilters` (в стиле существующего Autocomplete).
- Как именно протянуть `status`/даты в `ModalExport` (пропсы или контекст страницы) — без изменения UX модалки.
- Формат даты в фикстурах тестов, имена тестов.
## Decisions made without discussion
Человек сказал «делай по Jira» (12.11.2026) — дизайн взят из описания Jira; ниже решения, которых в Jira нет или которые от него отличаются.
1. **2026-11-12 — «давно закрытые» = то, что уже есть в `v3.vsp`, без загрузки архива.** Обоснование: вопрос к заказчику в Jira; по умолчанию там же предложен этот вариант, человек его подтвердил выбором «делай по Jira». Цена ошибки: если заказчику нужен архив извне — отдельная задача на импорт, текущая работа не теряется.
2. **2026-11-12 — ветка поверх AURORA-1885 (PR #328), не поверх 1886.** Обоснование: 1883 зависит только от немедленного закрытия (1885); 1886 с миграцией 0062 ждёт стека #315–#323, ставить 1883 за ним — задержать без нужды. Цена: если 1886 и 1883 сольются в другом порядке — возможен текстовый конфликт в `vsp_service.py` (разные методы, маловероятен).
3. **2026-11-12 — `status`→`is_active` чистой функцией `vsp_status_to_is_active`, `VSPStatus` в schemas.py** (D1). Обоснование: один перевод для списка и выгрузки, покрывается unit-тестом. Цена: нет.
4. **2026-11-12 — фильтр периода закрытия применяется при любом статусе на API, UI показывает поля только при «Закрытые»/«Все»** (D2). Обоснование: действующий ВСП может иметь будущую дату закрытия; запрещать комбинацию на API незачем. Цена: нет.
5. **2026-11-12 — `nulls_last` в сортировке закрытых, удаление мёртвого `return` в `get_list`** (D3). Обоснование: страховка и чистка в том же методе. Цена: нет.
6. **2026-11-12 — `SelectVspModal` не меняется** (D7). Обоснование: он делает собственный запрос dropdown без `include_inactive` и уже получает только действующие; Jira предлагала фильтровать по `is_active`, что избыточно. Цена: если кто-то переключит модалку на контекст — закрытые попадут в выбор; закрыто тем, что фильтр стоит в `VspDropdownEditCell`, но не в модалке — отметить комментарием в коде модалки.
7. **2026-11-12 — в `VspDropdownEditCell` текущий закрытый ВСП показывается подписью, но не выбирается** (D7). Обоснование: иначе поле открылось бы пустым и создало впечатление потери значения. Цена: нет.
8. **2026-11-12 — поля дат — `TextField type="date"`, переключатель — `ToggleButtonGroup`** (D6). Обоснование: `@mui/x-date-pickers` не объявлен в package.json (ARCHITECTURE.md, «Открытые вопросы»). Цена: вид поля даты — нативный браузерный.
9. **2026-11-12 — колонка «Статус» в таблице справочника — после «Дата закрытия ВСП»; подписи бейджей «Действует» / «Закрыт»** (как в Jira; цвета — как в `SspTable`). Цена: косметика.
Решения при исполнении (12.11.2026, executing-plans; маркеры R-n — в ledger и PR):
10. **2026-11-12 — R-1: переключатель статуса виден всем ролям, включая EXECUTOR_RF** (у него скрыт только выбор ССП). Обоснование: спека требует переключатель без оговорок по ролям; бэк ограничивает ССП исполнителя. Цена: если РФ не должен видеть закрытые — скрыть одну кнопку.
11. **2026-11-12 — R-2: загрузка списка ВСП отделена от загрузки ССП** (`loadVspList`, свой индикатор, устаревшие ответы отбрасываются счётчиком). Обоснование: иначе смена статуса перерисовывала страницу спиннером вместе с фильтрами. Цена: нет.
12. **2026-11-12 — R-3: созданный ВСП, не подходящий под текущий статус, не добавляется в список** (как при правке). Цена: нет.
13. **2026-11-12 — R-4: закрытый текущий ВСП в редакторе ячейки — скрытый disabled пункт меню**, чтобы Select показал подпись и MUI не ругался на значение вне списка. Цена: нет (оговорка по клавиатуре — M-5, отложено).
14. **2026-11-12 — R-5: `InputLabelProps` → `slotProps.inputLabel`** (MUI 9.1.1 убрал `InputLabelProps`; найдено ручной проверкой). Цена: нет.
15. **2026-11-12 — R-6 (финальное ревью I-1): админ грузит все ССП; в «Действующих» архивные ССП скрыты, в «Закрытые»/«Все» — показаны в фильтре и выгрузке; окно создания/правки ВСП — только активные ССП.** Обоснование: закрытые ВСП архивного ССП иначе видны, но не выгружаются. Цена: если архивных ССП с ВСП в данных нет — лишний пункт списка в режиме закрытых.

View File

@ -0,0 +1,36 @@
# Proposal
## Why
Замечание заказчика №3 «Справочники» (свод замечаний Фомичева от 27.10.2026, Jira AURORA-1883, эпик AURORA-1876, приоритет «Высокий»): «Предусмотреть возможность просмотра закрытых ВСП, в том числе давно закрытых».
Сейчас справочник ВСП показывает только действующие ВСП: `GET /api/v1/dict/info` всегда передаёт в сервис `is_active=True`, параметра статуса нет, выгрузка тоже отдаёт только действующие. ВСП с прошедшей датой закрытия автоматически получает `is_active=false` (после AURORA-1885 — сразу при сохранении) и пропадает из UI, хотя в `v3.vsp` остаётся. Посмотреть или исправить его нельзя. В листах формы 2 ячейка «ВСП РФ» строки с закрытым ВСП показывает не рег. номер, а id как число: подписи берутся из `/dict/info/dropdown`, где только действующие.
## What Changes
- `GET /dict/info`: новый query-параметр `status` (`active` | `closed` | `all`, по умолчанию `active`) и фильтр периода закрытия `close_date_start` / `close_date_end`. Закрытые сортируются по дате закрытия, свежие сверху.
- `POST /dict/info/export`: те же `status`, `close_date_start`, `close_date_end` в теле.
- `GET /dict/info/dropdown`: параметр `include_inactive` (по умолчанию `false`) — для подписей закрытых ВСП в листах форм. Удалённые (`is_deleted`) не возвращаются ни при каком статусе и ни в каком списке.
- Справочник ВСП (фронт): переключатель «Действующие / Закрытые / Все», поля «Закрыт с / по», колонка «Статус» с бейджем «Действует» / «Закрыт», приглушённый текст закрытых строк; выгрузка учитывает текущий статус и период.
- Листы формы 2: ячейка ВСП показывает рег. номер; у закрытого — «<рег. номер> (закрыт)»; если ВСП не найден — «ВСП #<id>», а не число. Выбрать для строки можно только действующий ВСП.
**Не меняется:**
- поведение по умолчанию: без параметров справочник, выгрузка и выпадающие списки отдают только действующие ВСП;
- права: исполнитель видит ВСП (и действующие, и закрытые) только своих ССП, админ — все; редактирование и удаление ВСП не меняются;
- автоматическое закрытие ВСП по дате (`_sync_closed_records`, AURORA-1885);
- снятие даты закрытия — AURORA-1886; удаление ВСП админом — AURORA-1884;
- загрузка «давно закрытых» ВСП из внешнего архива — вне задачи: показываем то, что есть в `v3.vsp`.
## Capabilities
### New Capabilities
- `vsp-directory`: список, фильтры по статусу и периоду закрытия, выгрузка и выпадающие списки справочника объектов ВСП; подпись ВСП в листах форм.
### Modified Capabilities
Нет.
## Impact
- Python: `api/src/api/v1/vsp.py` (`get_vsp`, `get_vsp_dropdown`, `export_info`), `api/src/domain/schemas.py` (`VSPExportRequest`), `api/src/services/vsp_service.py` (`get_list`, `get_dropdown`, `export_xlsx`), `api/src/repository/vsp_repository.py` (`get_list`). Миграции нет.
- Web: `web/src/pages/DictVspPage/` (`DictVspPage.jsx`, `components/DictVspFilters.jsx`, `components/TableDictVsp.jsx`, `components/Modals/ModalExport.jsx`), `web/src/components/RealtimeTable/` (`RealtimeTable.jsx`, `Cell/Cell/Cell.jsx`, `Cell/EditCell/VspDropdownEditCell.jsx`, `Modals/SelectVspModal.jsx`), новые чистые утилиты `utils/vspLabel.js`, `pages/DictVspPage/utils/vspListParams.js`.
- Тесты: `api/tests/integration/test_vsp.py`, `api/tests/unit/` (репозиторий/сервис), `node --test` на новые утилиты.

View File

@ -0,0 +1,94 @@
# Spec Delta
## Purpose
Справочник объектов ВСП (`/api/v1/dict/info`, страница «Справочник ВСП») и подпись ВСП в листах форм: какие ВСП попадают в список, выгрузку и выпадающие списки, как их фильтровать по статусу и периоду закрытия, как закрытый ВСП выглядит в справочнике и в ячейке формы.
Термины: **действующий ВСП** — запись `v3.vsp` с `is_deleted = false` и `is_active = true`; **закрытый ВСП** — `is_deleted = false` и `is_active = false` (дата закрытия наступила); **удалённый ВСП** — `is_deleted = true`.
## ADDED Requirements
### Requirement: Фильтр справочника по статусу
`GET /api/v1/dict/info` SHALL принимать query-параметр `status` со значениями `active`, `closed`, `all`; по умолчанию — `active`. `active` SHALL возвращать только действующие ВСП, `closed` — только закрытые, `all` — действующие и закрытые. Иное значение SHALL давать 422. Удалённые ВСП SHALL NOT возвращаться ни при каком значении `status`. Остальные фильтры (рег. номер, адрес, ССП, дата открытия, тип размещения, численность) и ограничение исполнителя своими ССП SHALL применяться как прежде, вместе со `status`.
#### Scenario: По умолчанию — только действующие
- **WHEN** администратор запрашивает `GET /dict/info` без `status`, а в справочнике есть действующий ВСП A и закрытый ВСП B
- **THEN** ответ содержит A и не содержит B
#### Scenario: Закрытые
- **WHEN** администратор запрашивает `GET /dict/info?status=closed`
- **THEN** ответ содержит B с `is_active = false` и не содержит A
#### Scenario: Все
- **WHEN** администратор запрашивает `GET /dict/info?status=all`
- **THEN** ответ содержит и A, и B
#### Scenario: Недопустимый статус
- **WHEN** запрос `GET /dict/info?status=bogus`
- **THEN** ответ — 422
#### Scenario: Удалённый не возвращается
- **WHEN** администратор удалил ВСП C (`DELETE /dict/info/{id}`) и запрашивает `status=active`, `closed` и `all`
- **THEN** ни один ответ не содержит C
#### Scenario: Исполнитель видит закрытые только своих ССП
- **WHEN** исполнитель, привязанный к ССП S1, запрашивает `GET /dict/info?status=closed`, а закрытые ВСП есть в S1 и в чужом S2
- **THEN** ответ содержит закрытые ВСП S1 и не содержит закрытые ВСП S2
### Requirement: Фильтр по периоду закрытия
`GET /api/v1/dict/info` SHALL принимать необязательные `close_date_start` и `close_date_end` (даты `YYYY-MM-DD`). При заданном `close_date_start` SHALL возвращаться только ВСП с датой закрытия не раньше неё, при заданном `close_date_end` — не позже неё (границы включительно). ВСП без даты закрытия SHALL NOT попадать в ответ, если задана хотя бы одна граница.
#### Scenario: ВСП закрыт в периоде
- **WHEN** ВСП B закрыт 2021-03-15 и запрос `GET /dict/info?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31`
- **THEN** ответ содержит B
#### Scenario: ВСП закрыт вне периода
- **WHEN** тот же ВСП B и запрос `?status=closed&close_date_start=2022-01-01`
- **THEN** ответ не содержит B
### Requirement: Порядок закрытых ВСП
При `status=closed` список SHALL быть отсортирован по дате закрытия по убыванию, при равной дате — по `id` по возрастанию. При `status=active` и `status=all` порядок SHALL оставаться прежним — по `id`.
#### Scenario: Свежезакрытые сверху
- **WHEN** ВСП B закрыт 2021-03-15, ВСП D закрыт 2024-06-01 и запрос `?status=closed`
- **THEN** D идёт в ответе раньше B
### Requirement: Выгрузка учитывает статус и период
`POST /api/v1/dict/info/export` SHALL принимать в теле `status` (`active` | `closed` | `all`, по умолчанию `active`), `close_date_start`, `close_date_end` и применять их так же, как `GET /dict/info`. Без этих полей выгрузка SHALL содержать только действующие ВСП, как раньше.
#### Scenario: Выгрузка закрытых
- **WHEN** администратор выгружает справочник с `status = closed`
- **THEN** файл содержит закрытый ВСП B и не содержит действующий A
### Requirement: Выпадающий список ВСП с закрытыми для подписей
`GET /api/v1/dict/info/dropdown` SHALL принимать параметр `include_inactive` (по умолчанию `false`). При `false` SHALL возвращаться только действующие ВСП, как раньше; при `true` — действующие и закрытые в тех же границах (ССП формы `form_id` или `ssp_id`, ССП исполнителя). Удалённые ВСП SHALL NOT возвращаться.
#### Scenario: Закрытый ВСП ССП формы
- **WHEN** у ССП формы F есть закрытый ВСП B и запрос `GET /dict/info/dropdown?form_id=F&include_inactive=true`
- **THEN** ответ содержит B с `is_active = false`
#### Scenario: Без параметра
- **WHEN** запрос `GET /dict/info/dropdown?form_id=F`
- **THEN** ответ не содержит B
### Requirement: Справочник ВСП показывает статус и позволяет найти закрытые
Страница «Справочник ВСП» SHALL иметь переключатель статуса «Действующие» (по умолчанию) / «Закрытые» / «Все» и, при «Закрытые» и «Все», поля периода закрытия «Закрыт с» / «по». Смена статуса или периода SHALL перезагружать список с сервера с этими параметрами. Таблица SHALL иметь колонку «Статус» с бейджем «Действует» или «Закрыт»; текст строк закрытых ВСП SHALL быть приглушённым. Выгрузка со страницы SHALL передавать текущие статус и период. Если после правки ВСП перестал подходить под выбранный статус (например, его закрыли в режиме «Действующие»), строка SHALL исчезать из списка.
#### Scenario: Найти давно закрытый ВСП
- **WHEN** администратор выбирает «Закрытые» и период «Закрыт с 01.01.2021 по 31.12.2021»
- **THEN** таблица показывает ВСП, закрытые в 2021 году, с бейджем «Закрыт»
#### Scenario: Закрыли в режиме «Действующие»
- **WHEN** в режиме «Действующие» администратор ставит ВСП дату закрытия «вчера» и сохраняет
- **THEN** строка этого ВСП пропадает из таблицы
### Requirement: Подпись ВСП в листах форм
Ячейка колонки с `editType = 'vsp_dropdown'` в листах форм SHALL показывать рег. номер ВСП, а не число: для действующего ВСП — рег. номер; для закрытого — «<рег. номер> (закрыт)»; если ВСП с таким id нет среди загруженных для формы (например, удалён) — «ВСП #<id>». Значение такой ячейки SHALL NOT форматироваться как сумма и SHALL NOT масштабироваться селектором разрядности. Для выбора ВСП в ячейке и при добавлении строки SHALL предлагаться только действующие ВСП. Поиск и фильтр по колонке ВСП SHALL находить и закрытые ВСП по рег. номеру.
#### Scenario: Строка с закрытым ВСП
- **WHEN** в листе FORM_2/AHR строка ссылается на закрытый ВСП с рег. номером `1234/567`, разрядность «тыс. руб.»
- **THEN** ячейка «ВСП РФ» показывает `1234/567 (закрыт)`
#### Scenario: Выбор ВСП
- **WHEN** пользователь открывает выбор ВСП в ячейке этой строки
- **THEN** в списке только действующие ВСП, закрытого `1234/567` в нём нет

View File

@ -6,6 +6,7 @@ import { resolveCellTextColor } from '../../utils/cellTextColor';
import { isTextColumn } from '../../utils/moneyUtils'; import { isTextColumn } from '../../utils/moneyUtils';
import { formatNumber } from '../../utils/numberFormat'; import { formatNumber } from '../../utils/numberFormat';
import { getRowId } from '../../utils/rowUtils'; import { getRowId } from '../../utils/rowUtils';
import { getVspLabel } from '../../utils/vspLabel';
const BASE_CELL_STYLES = { const BASE_CELL_STYLES = {
width: '100%', width: '100%',
@ -184,6 +185,10 @@ const CellComponent = ({
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => { const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
const value = cell.getValue(); const value = cell.getValue();
// AURORA-1883: id ВСП — не сумма; подпись (рег. номер, «(закрыт)», «ВСП #id») без разрядности.
if (isVspDropdown) {
return { rawValue: value, displayValue: getVspLabel(value, vspOptions) ?? '', isNumeric: false, isNegative: false };
}
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !isTextColumn(column); const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !isTextColumn(column);
let display = String(value || ''); let display = String(value || '');
let negative = false; let negative = false;
@ -193,13 +198,6 @@ const CellComponent = ({
negative = formatted.isNegative; negative = formatted.isNegative;
} }
if (isVspDropdown && value != null) {
const vsp = vspOptions?.find((v) => v.id === Number(value));
if (vsp) {
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false, isNegative: false };
}
}
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative }; return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]); }, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);

View File

@ -1,6 +1,7 @@
import { FormControl, MenuItem, Select } from '@mui/material'; import { FormControl, MenuItem, Select } from '@mui/material';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext'; import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext';
import { getVspLabel, selectableVspOptions } from '../../utils/vspLabel';
const EDITOR_STYLES = { const EDITOR_STYLES = {
position: 'absolute', position: 'absolute',
@ -11,6 +12,12 @@ const EDITOR_STYLES = {
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => { const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions(); const { endEditing: contextEndEditing } = useRealtimeActions();
const vspOptions = useVspOptions(); const vspOptions = useVspOptions();
// AURORA-1883: выбрать можно только действующий ВСП; закрытый текущий — подписан, но не выбирается.
const selectableOptions = useMemo(() => selectableVspOptions(vspOptions), [vspOptions]);
const currentIsSelectable = useMemo(
() => value == null || value === '' || selectableOptions.some((option) => Number(option.id) === Number(value)),
[selectableOptions, value],
);
const [selectedValue, setSelectedValue] = useState(() => value ?? ''); const [selectedValue, setSelectedValue] = useState(() => value ?? '');
const [position, setPosition] = useState(); const [position, setPosition] = useState();
@ -140,7 +147,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, c
<MenuItem value='' disabled> <MenuItem value='' disabled>
Выберите ВСП Выберите ВСП
</MenuItem> </MenuItem>
{vspOptions.map((option) => ( {!currentIsSelectable && (
<MenuItem value={value} disabled sx={{ display: 'none' }}>
{getVspLabel(value, vspOptions)}
</MenuItem>
)}
{selectableOptions.map((option) => (
<MenuItem key={option.id} value={option.id}> <MenuItem key={option.id} value={option.id}>
{option.registration_number} {option.registration_number}
</MenuItem> </MenuItem>

View File

@ -8,6 +8,7 @@ import { Cancel } from '../../common/icons/icons';
const SelectVspModalComponent = ({ isOpen, onClose, onSelect, formId, title = 'Выбор ВСП', isSaving = false }) => { const SelectVspModalComponent = ({ isOpen, onClose, onSelect, formId, title = 'Выбор ВСП', isSaving = false }) => {
const [selectedValue, setSelectedValue] = useState(''); const [selectedValue, setSelectedValue] = useState('');
// AURORA-1883: модалка сама грузит dropdown без include_inactive — в выборе только действующие ВСП.
const [vspOptions, setVspOption] = useState([]); const [vspOptions, setVspOption] = useState([]);
useEffect(() => { useEffect(() => {

View File

@ -301,7 +301,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
if (!hasVspDropdown(columnsConfig.columns)) return; if (!hasVspDropdown(columnsConfig.columns)) return;
let isCancelled = false; let isCancelled = false;
DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { // AURORA-1883: с закрытыми — для подписей строк на закрытых ВСП; выбор ограничен в VspDropdownEditCell.
DictVspApi.getDropdownVsp({ form_id: formId, include_inactive: true }).then((data) => {
if (!isCancelled && data.success) { if (!isCancelled && data.success) {
setVspOptions(data.result); setVspOptions(data.result);
} }

View File

@ -0,0 +1,17 @@
// AURORA-1883: подпись ВСП в ячейке листа формы (колонки editType 'vsp_dropdown').
// vspOptions — ответ /dict/info/dropdown?include_inactive=true: действующие и закрытые ВСП ССП формы.
const isEmptyValue = (value) => value === null || value === undefined || value === '';
/** Рег. номер ВСП; у закрытого — «<рег. номер> (закрыт)»; не найден — «ВСП #<id>»; пусто — null. */
export function getVspLabel(value, vspOptions) {
if (isEmptyValue(value)) return null;
const vsp = (vspOptions || []).find((option) => Number(option.id) === Number(value));
if (!vsp) return `ВСП #${value}`;
return vsp.is_active === false ? `${vsp.registration_number} (закрыт)` : vsp.registration_number;
}
/** ВСП, которые можно выбрать для строки: только действующие. */
export function selectableVspOptions(vspOptions) {
return (vspOptions || []).filter((option) => option.is_active !== false);
}

View File

@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getVspLabel, selectableVspOptions } from './vspLabel.js';
// AURORA-1883: в листах форм закрытый ВСП подписан рег. номером с пометкой, а не числом.
const opts = [
{ id: 1, registration_number: '1234/567', is_active: true },
{ id: 2, registration_number: '7777/001', is_active: false },
];
test('active VSP is labelled by its registration number', () => {
assert.equal(getVspLabel(1, opts), '1234/567');
});
test('closed VSP is labelled with the "(закрыт)" suffix', () => {
assert.equal(getVspLabel(2, opts), '7777/001 (закрыт)');
});
test('string id is matched like a number', () => {
assert.equal(getVspLabel('1', opts), '1234/567');
});
test('unknown id is labelled "ВСП #<id>", never formatted as a number', () => {
assert.equal(getVspLabel(99, opts), 'ВСП #99');
assert.equal(getVspLabel(5, undefined), 'ВСП #5');
});
test('empty value has no label', () => {
assert.equal(getVspLabel(null, opts), null);
assert.equal(getVspLabel(undefined, opts), null);
assert.equal(getVspLabel('', opts), null);
});
test('only active VSPs are selectable', () => {
assert.deepEqual(
selectableVspOptions(opts).map((v) => v.id),
[1],
);
assert.deepEqual(selectableVspOptions(undefined), []);
});

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style'; import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
import { Box, CircularProgress, Stack } from '@mui/material'; import { Box, CircularProgress, Stack } from '@mui/material';
@ -13,6 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer';
import DictVspFilters from './components/DictVspFilters'; import DictVspFilters from './components/DictVspFilters';
import DictVspModals from './components/DictVspModals'; import DictVspModals from './components/DictVspModals';
import TableDictVsp from './components/TableDictVsp'; import TableDictVsp from './components/TableDictVsp';
import { buildVspListParams, sspsForStatus, vspMatchesStatus } from './utils/vspListParams';
const DictVspInfo = () => { const DictVspInfo = () => {
const portalContent = ( const portalContent = (
@ -30,24 +31,57 @@ function DictVspPage() {
const [dictVspData, setDictVspData] = useState([]); const [dictVspData, setDictVspData] = useState([]);
const [ssps, setSsps] = useState([]); const [ssps, setSsps] = useState([]);
const [selectedSsp, setSelectedSsp] = useState(null); const [selectedSsp, setSelectedSsp] = useState(null);
// AURORA-1883: статус ВСП и период закрытия; список ВСП перезагружается с сервера при их смене.
const [status, setStatus] = useState('active');
const [closeDates, setCloseDates] = useState({ closeDateStart: '', closeDateEnd: '' });
const [isListLoading, setIsListLoading] = useState(true);
const listRequestRef = useRef(0);
const { user } = useAuth(); const { user } = useAuth();
const modalsRef = useRef(null); const modalsRef = useRef(null);
const listFilter = useMemo(() => ({ status, ...closeDates }), [status, closeDates]);
const visibleSsps = useMemo(() => sspsForStatus(ssps, status), [ssps, status]);
// Создание и правка ВСП — только на активном ССП (бэк отклоняет архивный).
const activeSsps = useMemo(() => sspsForStatus(ssps, 'active'), [ssps]);
// Выбранный архивный ССП исчезает из фильтра при возврате в «Действующие» — сбрасываем выбор.
useEffect(() => {
if (selectedSsp && !visibleSsps.some((ssp) => ssp.id === selectedSsp.id)) {
setSelectedSsp(null);
}
}, [selectedSsp, visibleSsps]);
const loadVspList = useCallback(async () => {
if (!user) return;
const requestId = ++listRequestRef.current;
setIsListLoading(true);
try {
const vspResponse = await DictVspApi.get(buildVspListParams(listFilter));
if (requestId !== listRequestRef.current) return;
if (!vspResponse.success) {
toast.error('Ошибка загрузки справочника');
return;
}
setDictVspData(vspResponse.result || []);
} catch (error) {
if (requestId !== listRequestRef.current) return;
toast.error(error.response?.data?.message || 'Ошибка загрузки справочника');
} finally {
if (requestId === listRequestRef.current) setIsListLoading(false);
}
}, [user, listFilter]);
useEffect(() => {
loadVspList();
}, [loadVspList]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!user) return; if (!user) return;
setIsLoading(true); setIsLoading(true);
try { try {
const isAdmin = user.role_id === ROLES_NAME_ID.admin; const isAdmin = user.role_id === ROLES_NAME_ID.admin;
const [vspResponse, sspResponse] = await Promise.all([ // AURORA-1883: все ССП, включая архивные, — для закрытых ВСП; в «Действующих» архивные скрываются (sspsForStatus).
DictVspApi.get(), const sspResponse = isAdmin ? await SspApi.getAll() : null;
isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null),
]);
if (!vspResponse.success) {
toast.error('Ошибка загрузки справочника');
return;
}
setDictVspData(vspResponse.result || []);
if (isAdmin) { if (isAdmin) {
if (!sspResponse.success) { if (!sspResponse.success) {
@ -70,37 +104,49 @@ function DictVspPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
const handleAdd = useCallback(async (formData) => { const handleAdd = useCallback(
try { async (formData) => {
const response = await DictVspApi.create(formData); try {
if (response.success) { const response = await DictVspApi.create(formData);
toast.success('ВСП успешно создан'); if (response.success) {
setDictVspData((prev) => [...prev, response.result]); toast.success('ВСП успешно создан');
} else { if (vspMatchesStatus(response.result, status)) {
toast.error(response.message || 'Ошибка создания ВСП'); setDictVspData((prev) => [...prev, response.result]);
}
} else {
toast.error(response.message || 'Ошибка создания ВСП');
}
} catch (error) {
console.error('Error adding VSP:', error);
toast.error(error.response?.data?.message || 'Ошибка создания ВСП');
throw error;
} }
} catch (error) { },
console.error('Error adding VSP:', error); [status],
toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); );
throw error;
}
}, []);
const handleEdit = useCallback(async (formData) => { const handleEdit = useCallback(
try { async (formData) => {
const response = await DictVspApi.update(formData.id, formData); try {
if (response.success) { const response = await DictVspApi.update(formData.id, formData);
toast.success('ВСП успешно обновлен'); if (response.success) {
setDictVspData((prev) => [...prev.filter((v) => v.id !== formData.id), response.result]); toast.success('ВСП успешно обновлен');
} else { // AURORA-1883: ВСП, переставший подходить под статус (например, закрыт в «Действующих»), уходит из списка.
toast.error(response.message || 'Ошибка обновления ВСП'); setDictVspData((prev) => {
const rest = prev.filter((v) => v.id !== formData.id);
return vspMatchesStatus(response.result, status) ? [...rest, response.result] : rest;
});
} else {
toast.error(response.message || 'Ошибка обновления ВСП');
}
} catch (error) {
console.error('Error editing VSP:', error);
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП');
throw error;
} }
} catch (error) { },
console.error('Error editing VSP:', error); [status],
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); );
throw error;
}
}, []);
const handleDelete = useCallback(async (vspId) => { const handleDelete = useCallback(async (vspId) => {
try { try {
@ -140,23 +186,33 @@ function DictVspPage() {
<PageContainer> <PageContainer>
<Stack sx={{ alignItems: 'stretch', width: '100%' }}> <Stack sx={{ alignItems: 'stretch', width: '100%' }}>
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
{user.role_id !== ROLES_NAME_ID.executor_rf && ( <DictVspFilters
<DictVspFilters ssps={ssps} onSspChange={setSelectedSsp} selectedSsp={selectedSsp} /> ssps={visibleSsps}
)} onSspChange={setSelectedSsp}
selectedSsp={selectedSsp}
showSsp={user.role_id !== ROLES_NAME_ID.executor_rf}
status={status}
onStatusChange={setStatus}
closeDateStart={closeDates.closeDateStart}
closeDateEnd={closeDates.closeDateEnd}
onCloseDateChange={setCloseDates}
/>
<TableDictVsp <TableDictVsp
data={dictVspData} data={dictVspData}
selectedSsp={selectedSsp} selectedSsp={selectedSsp}
onAddClick={handleAddClick} onAddClick={handleAddClick}
onEditClick={handleEditClick} onEditClick={handleEditClick}
onExportClick={handleExportClick} onExportClick={handleExportClick}
loading={isLoading} loading={isLoading || isListLoading}
/> />
<DictVspModals <DictVspModals
ref={modalsRef} ref={modalsRef}
ssps={ssps} ssps={activeSsps}
exportSsps={visibleSsps}
onAdd={handleAdd} onAdd={handleAdd}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
listFilter={listFilter}
/> />
</Box> </Box>
</Stack> </Stack>

View File

@ -1,5 +1,6 @@
import { Autocomplete, Box, Paper, Stack, TextField } from '@mui/material'; import { Autocomplete, Box, Paper, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { VSP_STATUS_OPTIONS } from '../utils/vspListParams';
const AutocompleteStyled = styled(Autocomplete)` const AutocompleteStyled = styled(Autocomplete)`
& .MuiOutlinedInput-root { & .MuiOutlinedInput-root {
@ -23,7 +24,22 @@ const AutocompleteStyled = styled(Autocomplete)`
} }
`; `;
const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => { const DATE_INPUT_SX = {
width: '11rem',
'& .MuiOutlinedInput-root': { backgroundColor: 'white', height: '2.56rem', borderRadius: '0.63rem' },
};
const DictVspFilters = ({
ssps,
selectedSsp = null,
onSspChange,
showSsp = true,
status = 'active',
onStatusChange,
closeDateStart = '',
closeDateEnd = '',
onCloseDateChange,
}) => {
return ( return (
<Paper <Paper
sx={{ sx={{
@ -37,19 +53,56 @@ const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => {
gap: '1rem', gap: '1rem',
mb: '1.5rem', mb: '1.5rem',
}}> }}>
<Stack direction={'row'} spacing={'1rem'}> <Stack direction={'row'} spacing={'1rem'} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: '1rem' }}>
<Box sx={{ width: '30rem' }}> {showSsp && (
<AutocompleteStyled <Box sx={{ width: '30rem' }}>
size='small' <AutocompleteStyled
options={ssps || []} size='small'
noOptionsText='Не найдено' options={ssps || []}
getOptionLabel={(option) => option.title || option.name || ''} noOptionsText='Не найдено'
value={selectedSsp} getOptionLabel={(option) => option.title || option.name || ''}
getOptionKey={(option) => option.id || option.name || option.title} value={selectedSsp}
onChange={(event, newValue) => onSspChange(newValue)} getOptionKey={(option) => option.id || option.name || option.title}
renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />} onChange={(event, newValue) => onSspChange(newValue)}
/> renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />}
</Box> />
</Box>
)}
{/* AURORA-1883: статус ВСП и период закрытия */}
<ToggleButtonGroup
exclusive
size='small'
value={status}
onChange={(_event, value) => value && onStatusChange?.(value)}
sx={{ backgroundColor: 'white', height: '2.56rem' }}>
{VSP_STATUS_OPTIONS.map((option) => (
<ToggleButton key={option.value} value={option.value} sx={{ textTransform: 'none', px: '1rem' }}>
{option.label}
</ToggleButton>
))}
</ToggleButtonGroup>
{status !== 'active' && (
<>
<TextField
type='date'
size='small'
label='Закрыт с'
value={closeDateStart}
onChange={(event) => onCloseDateChange?.({ closeDateStart: event.target.value, closeDateEnd })}
slotProps={{ inputLabel: { shrink: true } }}
sx={DATE_INPUT_SX}
/>
<TextField
type='date'
size='small'
label='по'
value={closeDateEnd}
onChange={(event) => onCloseDateChange?.({ closeDateStart, closeDateEnd: event.target.value })}
slotProps={{ inputLabel: { shrink: true } }}
sx={DATE_INPUT_SX}
/>
</>
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@ -3,7 +3,7 @@ import ModalEditAddVsp from './Modals/ModalEditAddVsp';
import ModalExport from './Modals/ModalExport'; import ModalExport from './Modals/ModalExport';
// Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT. // Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT.
const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, exportSsps, listFilter }, ref) => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalExportOpen, setModalExportOpen] = useState(false); const [modalExportOpen, setModalExportOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => {
loading={saveLoading} loading={saveLoading}
ssps={ssps} ssps={ssps}
/> />
<ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} /> <ModalExport open={modalExportOpen} onClose={closeExport} ssps={exportSsps ?? ssps} listFilter={listFilter} />
</> </>
); );
}); });

View File

@ -7,9 +7,10 @@ import Checkbox from '../../../../components/common/Checkbox';
import Modal from '../../../../components/common/Modal/Modal'; import Modal from '../../../../components/common/Modal/Modal';
import SearchComponent from '../../../../components/common/SearchComponent'; import SearchComponent from '../../../../components/common/SearchComponent';
import { downloadFile } from '../../../../utils/downloadFile'; import { downloadFile } from '../../../../utils/downloadFile';
import { buildVspListParams } from '../../utils/vspListParams';
import { ModalContainer } from './Modal.style'; import { ModalContainer } from './Modal.style';
const ModalExport = ({ open, onClose, ssps = [], loading = false }) => { const ModalExport = ({ open, onClose, ssps = [], loading = false, listFilter = { status: 'active' } }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedSsps, setSelectedSsps] = useState([]); const [selectedSsps, setSelectedSsps] = useState([]);
@ -45,8 +46,10 @@ const ModalExport = ({ open, onClose, ssps = [], loading = false }) => {
const handleExport = async () => { const handleExport = async () => {
if (selectedSsps.length > 0) { if (selectedSsps.length > 0) {
try { try {
// AURORA-1883: выгрузка по текущему статусу и периоду закрытия справочника.
const response = await DictVspApi.export({ const response = await DictVspApi.export({
ssp_ids: selectedSsps, ssp_ids: selectedSsps,
...buildVspListParams(listFilter),
}); });
await downloadFile(response, 'Справочник ВСП', 'csv'); await downloadFile(response, 'Справочник ВСП', 'csv');
} catch (error) { } catch (error) {

View File

@ -125,6 +125,36 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
}, },
{
// AURORA-1883: статус ВСП — как бейдж «Активен»/«Архив» в SspTable.
accessorKey: 'is_active',
header: 'Статус',
size: 110,
minSize: 100,
maxSize: 130,
Cell: ({ row }) => {
const isActive = row.original.is_active !== false;
return (
<Box
component='span'
sx={{
border: isActive ? '.0625rem solid #bedbff' : '.0625rem solid rgba(0, 0, 0, 0.1)',
borderRadius: '0.5rem',
padding: '0.12rem 0.5rem',
background: isActive ? '#e8f2ff' : '#f3f4f6',
color: isActive ? '#155dfc' : '#99a1af',
fontSize: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
width: 'max-content',
}}>
{isActive ? 'Действует' : 'Закрыт'}
</Box>
);
},
enableColumnActions: false,
enableSorting: false,
},
{ {
accessorKey: 'placement_type', accessorKey: 'placement_type',
header: 'Форма расположения ВСП', header: 'Форма расположения ВСП',
@ -412,15 +442,16 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
}, },
}, },
muiTableBodyCellProps: { muiTableBodyCellProps: ({ row }) => ({
sx: { sx: {
padding: '0.75rem', padding: '0.75rem',
fontSize: '0.75rem', fontSize: '0.75rem',
lineHeight: '150%', lineHeight: '150%',
color: '#364153', // AURORA-1883: текст строк закрытых ВСП приглушён.
color: row.original.is_active === false ? '#99a1af' : '#364153',
whiteSpace: 'nowrap', // Предотвращаем перенос текста whiteSpace: 'nowrap', // Предотвращаем перенос текста
}, },
}, }),
muiTableHeadCellProps: { muiTableHeadCellProps: {
sx: { sx: {

View File

@ -0,0 +1,29 @@
// AURORA-1883: статус справочника ВСП — 'active' (по умолчанию) | 'closed' | 'all'.
export const VSP_STATUS_OPTIONS = [
{ value: 'active', label: 'Действующие' },
{ value: 'closed', label: 'Закрытые' },
{ value: 'all', label: 'Все' },
];
/** Параметры GET /dict/info и тела выгрузки: период закрытия — только для «Закрытые»/«Все». */
export function buildVspListParams({ status, closeDateStart, closeDateEnd }) {
const params = { status };
if (status === 'active') return params;
if (closeDateStart) params.close_date_start = closeDateStart;
if (closeDateEnd) params.close_date_end = closeDateEnd;
return params;
}
/** Подходит ли ВСП под выбранный статус (после правки строка может выпасть из списка). */
export function vspMatchesStatus(vsp, status) {
if (status === 'active') return vsp.is_active !== false;
if (status === 'closed') return vsp.is_active === false;
return true;
}
/** ССП для фильтра и выгрузки: архивные — только при «Закрытые»/«Все» (закрытые ВСП бывают на архивных ССП). */
export function sspsForStatus(ssps, status) {
const list = ssps || [];
return status === 'active' ? list.filter((ssp) => ssp.is_active !== false) : list;
}

View File

@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVspListParams, sspsForStatus, vspMatchesStatus } from './vspListParams.js';
// AURORA-1883: параметры запроса справочника ВСП по статусу и периоду закрытия.
test('active status sends no close period', () => {
assert.deepEqual(buildVspListParams({ status: 'active', closeDateStart: '2021-01-01', closeDateEnd: '' }), {
status: 'active',
});
});
test('closed status sends both close dates', () => {
assert.deepEqual(buildVspListParams({ status: 'closed', closeDateStart: '2021-01-01', closeDateEnd: '2021-12-31' }), {
status: 'closed',
close_date_start: '2021-01-01',
close_date_end: '2021-12-31',
});
});
test('all status sends only the filled close date', () => {
assert.deepEqual(buildVspListParams({ status: 'all', closeDateStart: '', closeDateEnd: '2021-12-31' }), {
status: 'all',
close_date_end: '2021-12-31',
});
});
test('vspMatchesStatus follows is_active', () => {
assert.equal(vspMatchesStatus({ is_active: true }, 'active'), true);
assert.equal(vspMatchesStatus({ is_active: false }, 'active'), false);
assert.equal(vspMatchesStatus({ is_active: false }, 'closed'), true);
assert.equal(vspMatchesStatus({ is_active: true }, 'closed'), false);
assert.equal(vspMatchesStatus({ is_active: false }, 'all'), true);
});
// AURORA-1883, финальное ревью I-1: закрытые ВСП архивного ССП должны фильтроваться и выгружаться.
test('sspsForStatus hides archived SSPs only for active VSPs', () => {
const ssps = [
{ id: 1, is_active: true },
{ id: 2, is_active: false },
];
assert.deepEqual(
sspsForStatus(ssps, 'active').map((s) => s.id),
[1],
);
assert.deepEqual(
sspsForStatus(ssps, 'closed').map((s) => s.id),
[1, 2],
);
assert.deepEqual(
sspsForStatus(ssps, 'all').map((s) => s.id),
[1, 2],
);
assert.deepEqual(sspsForStatus(undefined, 'active'), []);
});