AURORA-1883: справочник ВСП — просмотр закрытых ВСП (статус, период закрытия), подпись закрытых ВСП в формах (поверх #328) #332

Merged
aep merged 9 commits from feat/330-aurora-1883-closed-vsp into test 2026-11-12 17:04:48 +03:00
24 changed files with 1060 additions and 88 deletions

View File

@ -13,9 +13,10 @@ from src.domain.schemas import (
VSPCreate,
VSPExportRequest,
VSPInDB,
VSPStatus,
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"])
@ -30,6 +31,9 @@ async def get_vsp(
placement_type: str | None = None,
staff_count_min: 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),
current_user: AppUser = Depends(require_executor),
):
@ -46,7 +50,9 @@ async def get_vsp(
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
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,
)
return BaseListResponse(
@ -61,12 +67,15 @@ async def get_vsp(
async def get_vsp_dropdown(
ssp_id: int | None = None,
form_id: int | None = None,
include_inactive: bool = False,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записей для выпадающего списка INFO."""
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(
success=True,
message="Список INFO для выпадающего списка",
@ -93,6 +102,9 @@ async def export_info(
placement_type=body.placement_type,
staff_count_min=body.staff_count_min,
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(
stream,

View File

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

View File

@ -59,7 +59,8 @@ class VSPRepository:
staff_count_min: int | None = None,
staff_count_max: int | None = None,
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,
with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]:
@ -78,6 +79,8 @@ class VSPRepository:
("staff_count_min", staff_count_min),
("staff_count_max", staff_count_max),
("is_active", is_active),
("close_date_start", close_date_start),
("close_date_end", close_date_end),
]
for filter_name, filter_value in filters:
# print(filter_name, filter_value)
@ -98,14 +101,13 @@ class VSPRepository:
queries.append(Vsp.staff_count <= value)
case ("is_active", value) if value is not None:
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 _:
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 not ssp_ids:
if with_count:
@ -116,7 +118,11 @@ class VSPRepository:
query = query.where(*queries)
if load_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:
result = (await self.db.execute(query)).all()
@ -131,7 +137,6 @@ class VSPRepository:
return (await self.db.execute(count_query)).scalar(), []
else:
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:
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.core.errors import AccessDeniedException, ValidationException
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.user_repository import UserRepository
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
_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:
EXECUTOR_EDITABLE_FIELDS = {
@ -64,6 +71,8 @@ class VSPService:
staff_count_min: int | None = None,
staff_count_max: int | None = None,
is_active: bool | None = None,
close_date_start: date | None = None,
close_date_end: date | None = None,
load_org_unit: bool = False,
with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]:
@ -94,6 +103,8 @@ class VSPService:
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
is_active=is_active,
close_date_start=close_date_start,
close_date_end=close_date_end,
load_org_unit=load_org_unit,
with_count=with_count,
)
@ -104,6 +115,7 @@ class VSPService:
ssp_id: int | None = None,
form_id: int | None = None,
with_count: bool = False,
include_inactive: bool = False,
) -> list[Vsp]:
ssp_ids = await self._get_scoped_ssp_ids(user=user)
@ -125,9 +137,10 @@ class VSPService:
ssp_ids = list(checking_ssps & set(ssp_ids))
await self._sync_closed_records()
# AURORA-1883: include_inactive — с закрытыми, для подписей ВСП в листах форм
return await self.vsp_repo.get_list(
ssp_ids=ssp_ids,
is_active=True,
is_active=None if include_inactive else True,
with_count=with_count,
)
@ -166,6 +179,9 @@ class VSPService:
placement_type: str | None = None,
staff_count_min: 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]:
data = await self.get_list(
user=user,
@ -177,7 +193,9 @@ class VSPService:
placement_type=placement_type,
staff_count_min=staff_count_min,
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,
)

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)
assert changes["closed_at"]["before"] is not 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 { formatNumber } from '../../utils/numberFormat';
import { getRowId } from '../../utils/rowUtils';
import { getVspLabel } from '../../utils/vspLabel';
const BASE_CELL_STYLES = {
width: '100%',
@ -184,6 +185,10 @@ const CellComponent = ({
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
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);
let display = String(value || '');
let negative = false;
@ -193,13 +198,6 @@ const CellComponent = ({
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 };
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);

View File

@ -1,6 +1,7 @@
import { FormControl, MenuItem, Select } from '@mui/material';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext';
import { getVspLabel, selectableVspOptions } from '../../utils/vspLabel';
const EDITOR_STYLES = {
position: 'absolute',
@ -11,6 +12,12 @@ const EDITOR_STYLES = {
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
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 [position, setPosition] = useState();
@ -140,7 +147,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, c
<MenuItem value='' disabled>
Выберите ВСП
</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}>
{option.registration_number}
</MenuItem>

View File

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

View File

@ -301,7 +301,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
if (!hasVspDropdown(columnsConfig.columns)) return;
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) {
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 { Box, CircularProgress, Stack } from '@mui/material';
@ -13,6 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer';
import DictVspFilters from './components/DictVspFilters';
import DictVspModals from './components/DictVspModals';
import TableDictVsp from './components/TableDictVsp';
import { buildVspListParams, sspsForStatus, vspMatchesStatus } from './utils/vspListParams';
const DictVspInfo = () => {
const portalContent = (
@ -30,24 +31,57 @@ function DictVspPage() {
const [dictVspData, setDictVspData] = useState([]);
const [ssps, setSsps] = useState([]);
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 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 () => {
if (!user) return;
setIsLoading(true);
try {
const isAdmin = user.role_id === ROLES_NAME_ID.admin;
const [vspResponse, sspResponse] = await Promise.all([
DictVspApi.get(),
isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null),
]);
if (!vspResponse.success) {
toast.error('Ошибка загрузки справочника');
return;
}
setDictVspData(vspResponse.result || []);
// AURORA-1883: все ССП, включая архивные, — для закрытых ВСП; в «Действующих» архивные скрываются (sspsForStatus).
const sspResponse = isAdmin ? await SspApi.getAll() : null;
if (isAdmin) {
if (!sspResponse.success) {
@ -70,37 +104,49 @@ function DictVspPage() {
loadData();
}, [loadData]);
const handleAdd = useCallback(async (formData) => {
try {
const response = await DictVspApi.create(formData);
if (response.success) {
toast.success('ВСП успешно создан');
setDictVspData((prev) => [...prev, response.result]);
} else {
toast.error(response.message || 'Ошибка создания ВСП');
const handleAdd = useCallback(
async (formData) => {
try {
const response = await DictVspApi.create(formData);
if (response.success) {
toast.success('ВСП успешно создан');
if (vspMatchesStatus(response.result, status)) {
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);
toast.error(error.response?.data?.message || 'Ошибка создания ВСП');
throw error;
}
}, []);
},
[status],
);
const handleEdit = useCallback(async (formData) => {
try {
const response = await DictVspApi.update(formData.id, formData);
if (response.success) {
toast.success('ВСП успешно обновлен');
setDictVspData((prev) => [...prev.filter((v) => v.id !== formData.id), response.result]);
} else {
toast.error(response.message || 'Ошибка обновления ВСП');
const handleEdit = useCallback(
async (formData) => {
try {
const response = await DictVspApi.update(formData.id, formData);
if (response.success) {
toast.success('ВСП успешно обновлен');
// AURORA-1883: ВСП, переставший подходить под статус (например, закрыт в «Действующих»), уходит из списка.
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);
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП');
throw error;
}
}, []);
},
[status],
);
const handleDelete = useCallback(async (vspId) => {
try {
@ -140,23 +186,33 @@ function DictVspPage() {
<PageContainer>
<Stack sx={{ alignItems: 'stretch', width: '100%' }}>
<Box sx={{ width: '100%' }}>
{user.role_id !== ROLES_NAME_ID.executor_rf && (
<DictVspFilters ssps={ssps} onSspChange={setSelectedSsp} selectedSsp={selectedSsp} />
)}
<DictVspFilters
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
data={dictVspData}
selectedSsp={selectedSsp}
onAddClick={handleAddClick}
onEditClick={handleEditClick}
onExportClick={handleExportClick}
loading={isLoading}
loading={isLoading || isListLoading}
/>
<DictVspModals
ref={modalsRef}
ssps={ssps}
ssps={activeSsps}
exportSsps={visibleSsps}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
listFilter={listFilter}
/>
</Box>
</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 { VSP_STATUS_OPTIONS } from '../utils/vspListParams';
const AutocompleteStyled = styled(Autocomplete)`
& .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 (
<Paper
sx={{
@ -37,19 +53,56 @@ const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => {
gap: '1rem',
mb: '1.5rem',
}}>
<Stack direction={'row'} spacing={'1rem'}>
<Box sx={{ width: '30rem' }}>
<AutocompleteStyled
size='small'
options={ssps || []}
noOptionsText='Не найдено'
getOptionLabel={(option) => option.title || option.name || ''}
value={selectedSsp}
getOptionKey={(option) => option.id || option.name || option.title}
onChange={(event, newValue) => onSspChange(newValue)}
renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />}
/>
</Box>
<Stack direction={'row'} spacing={'1rem'} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: '1rem' }}>
{showSsp && (
<Box sx={{ width: '30rem' }}>
<AutocompleteStyled
size='small'
options={ssps || []}
noOptionsText='Не найдено'
getOptionLabel={(option) => option.title || option.name || ''}
value={selectedSsp}
getOptionKey={(option) => option.id || option.name || option.title}
onChange={(event, newValue) => onSspChange(newValue)}
renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />}
/>
</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>
</Paper>
);

View File

@ -3,7 +3,7 @@ import ModalEditAddVsp from './Modals/ModalEditAddVsp';
import ModalExport from './Modals/ModalExport';
// Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает 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 [modalExportOpen, setModalExportOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => {
loading={saveLoading}
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 SearchComponent from '../../../../components/common/SearchComponent';
import { downloadFile } from '../../../../utils/downloadFile';
import { buildVspListParams } from '../../utils/vspListParams';
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 [selectedSsps, setSelectedSsps] = useState([]);
@ -45,8 +46,10 @@ const ModalExport = ({ open, onClose, ssps = [], loading = false }) => {
const handleExport = async () => {
if (selectedSsps.length > 0) {
try {
// AURORA-1883: выгрузка по текущему статусу и периоду закрытия справочника.
const response = await DictVspApi.export({
ssp_ids: selectedSsps,
...buildVspListParams(listFilter),
});
await downloadFile(response, 'Справочник ВСП', 'csv');
} catch (error) {

View File

@ -125,6 +125,36 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
enableColumnActions: 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',
header: 'Форма расположения ВСП',
@ -412,15 +442,16 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
},
},
muiTableBodyCellProps: {
muiTableBodyCellProps: ({ row }) => ({
sx: {
padding: '0.75rem',
fontSize: '0.75rem',
lineHeight: '150%',
color: '#364153',
// AURORA-1883: текст строк закрытых ВСП приглушён.
color: row.original.is_active === false ? '#99a1af' : '#364153',
whiteSpace: 'nowrap', // Предотвращаем перенос текста
},
},
}),
muiTableHeadCellProps: {
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'), []);
});