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

Merged
aep merged 9 commits from feat/330-aurora-1883-closed-vsp into test 2026-11-12 17:04:48 +03:00
6 changed files with 149 additions and 11 deletions
Showing only changes of commit 972dce8994 - Show all commits

View File

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

View File

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

View File

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

View File

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

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))
assert listing.status_code == 200
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