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:
parent
99ab7598c9
commit
972dce8994
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
8
api/tests/unit/test_vsp_status.py
Normal file
8
api/tests/unit/test_vsp_status.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user