feat(vsp): фильтр справочника по статусу и периоду закрытия (AURORA-1883, #330)

GET /dict/info: status=active|closed|all (по умолчанию active), close_date_start/end;
закрытые сортируются по дате закрытия по убыванию. Удалён неиспользуемый
close_date_is_null и мёртвый return в VSPRepository.get_list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 11:46:19 +04:00
parent 99ab7598c9
commit 972dce8994
6 changed files with 149 additions and 11 deletions

View File

@ -13,9 +13,10 @@ from src.domain.schemas import (
VSPCreate, VSPCreate,
VSPExportRequest, VSPExportRequest,
VSPInDB, VSPInDB,
VSPStatus,
VSPUpdate, VSPUpdate,
) )
from src.services.vsp_service import VSPService from src.services.vsp_service import VSPService, vsp_status_to_is_active
router = APIRouter(prefix="/dict/info", tags=["info"]) router = APIRouter(prefix="/dict/info", tags=["info"])
@ -30,6 +31,9 @@ async def get_vsp(
placement_type: str | None = None, placement_type: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
status: VSPStatus = "active",
close_date_start: date | None = None,
close_date_end: date | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor), current_user: AppUser = Depends(require_executor),
): ):
@ -46,7 +50,9 @@ async def get_vsp(
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
load_org_unit=True, load_org_unit=True,
is_active=True, is_active=vsp_status_to_is_active(status),
close_date_start=close_date_start,
close_date_end=close_date_end,
with_count=True, with_count=True,
) )
return BaseListResponse( return BaseListResponse(

View File

@ -745,6 +745,10 @@ class VSPUpdate(VSPEditBase):
) )
# AURORA-1883: статус справочника ВСП — действующие / закрытые / все.
VSPStatus = Literal["active", "closed", "all"]
class VSPInDB(VSPBase): class VSPInDB(VSPBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int

View File

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

View File

@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.budget_form_repository import BudgetFormRepository from src.repository.budget_form_repository import BudgetFormRepository
from src.core.errors import AccessDeniedException, ValidationException from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate from src.domain.schemas import VSPCreate, VSPStatus, VSPUpdate
from src.repository.org_unit_repository import OrgUnitRepository from src.repository.org_unit_repository import OrgUnitRepository
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
from src.repository.vsp_repository import VSPRepository from src.repository.vsp_repository import VSPRepository
@ -18,6 +18,13 @@ from src.repository.vsp_repository import VSPRepository
_last_info_close_sync_run: Optional[date] = None _last_info_close_sync_run: Optional[date] = None
_info_close_sync_lock = asyncio.Lock() _info_close_sync_lock = asyncio.Lock()
_STATUS_TO_IS_ACTIVE: dict[str, bool | None] = {"active": True, "closed": False, "all": None}
def vsp_status_to_is_active(status: VSPStatus) -> bool | None:
"""AURORA-1883: статус справочника → фильтр is_active (None — без фильтра)."""
return _STATUS_TO_IS_ACTIVE[status]
class VSPService: class VSPService:
EXECUTOR_EDITABLE_FIELDS = { EXECUTOR_EDITABLE_FIELDS = {
@ -64,6 +71,8 @@ class VSPService:
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
is_active: bool | None = None, is_active: bool | None = None,
close_date_start: date | None = None,
close_date_end: date | None = None,
load_org_unit: bool = False, load_org_unit: bool = False,
with_count: bool = False, with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]: ) -> list[Vsp] | tuple[int, list[Vsp]]:
@ -94,6 +103,8 @@ class VSPService:
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
is_active=is_active, is_active=is_active,
close_date_start=close_date_start,
close_date_end=close_date_end,
load_org_unit=load_org_unit, load_org_unit=load_org_unit,
with_count=with_count, with_count=with_count,
) )

View File

@ -353,3 +353,107 @@ def test_vsp_update_past_close_date_deactivates(client, admin_tokens, auth_heade
listing = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens)) listing = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens))
assert listing.status_code == 200 assert listing.status_code == 200
assert vsp_id not in [item["id"] for item in listing.json()["result"]] assert vsp_id not in [item["id"] for item in listing.json()["result"]]
# --- 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

View File

@ -0,0 +1,8 @@
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