AURORA-1883: справочник ВСП — просмотр закрытых ВСП (статус, период закрытия), подпись закрытых ВСП в формах (поверх #328) #332
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
58
api/tests/unit/test_vsp_status.py
Normal file
58
api/tests/unit/test_vsp_status.py
Normal 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
|
||||
195
docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md
Normal file
195
docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md
Normal 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)`.
|
||||
2
openspec/changes/aurora-1883-closed-vsp/.openspec.yaml
Normal file
2
openspec/changes/aurora-1883-closed-vsp/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-12
|
||||
109
openspec/changes/aurora-1883-closed-vsp/design.md
Normal file
109
openspec/changes/aurora-1883-closed-vsp/design.md
Normal 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): админ грузит все ССП; в «Действующих» архивные ССП скрыты, в «Закрытые»/«Все» — показаны в фильтре и выгрузке; окно создания/правки ВСП — только активные ССП.** Обоснование: закрытые ВСП архивного ССП иначе видны, но не выгружаются. Цена: если архивных ССП с ВСП в данных нет — лишний пункт списка в режиме закрытых.
|
||||
36
openspec/changes/aurora-1883-closed-vsp/proposal.md
Normal file
36
openspec/changes/aurora-1883-closed-vsp/proposal.md
Normal 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` на новые утилиты.
|
||||
@ -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` в нём нет
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
17
web/src/components/RealtimeTable/utils/vspLabel.js
Normal file
17
web/src/components/RealtimeTable/utils/vspLabel.js
Normal 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);
|
||||
}
|
||||
40
web/src/components/RealtimeTable/utils/vspLabel.test.js
Normal file
40
web/src/components/RealtimeTable/utils/vspLabel.test.js
Normal 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), []);
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
29
web/src/pages/DictVspPage/utils/vspListParams.js
Normal file
29
web/src/pages/DictVspPage/utils/vspListParams.js
Normal 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;
|
||||
}
|
||||
54
web/src/pages/DictVspPage/utils/vspListParams.test.js
Normal file
54
web/src/pages/DictVspPage/utils/vspListParams.test.js
Normal 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'), []);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user