From 972dce8994a0cfd9b4db29670f0ba2d4079061f8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:46:19 +0400 Subject: [PATCH] =?UTF-8?q?feat(vsp):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D1=83=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20(AU?= =?UTF-8?q?RORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/src/api/v1/vsp.py | 10 ++- api/src/domain/schemas.py | 4 ++ api/src/repository/vsp_repository.py | 21 +++--- api/src/services/vsp_service.py | 13 +++- api/tests/integration/test_vsp.py | 104 +++++++++++++++++++++++++++ api/tests/unit/test_vsp_status.py | 8 +++ 6 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 api/tests/unit/test_vsp_status.py diff --git a/api/src/api/v1/vsp.py b/api/src/api/v1/vsp.py index 0a2e0b3..937bf72 100644 --- a/api/src/api/v1/vsp.py +++ b/api/src/api/v1/vsp.py @@ -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( diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 2685b36..d5cd68e 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -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 diff --git a/api/src/repository/vsp_repository.py b/api/src/repository/vsp_repository.py index 1cd5caa..57a07e6 100644 --- a/api/src/repository/vsp_repository.py +++ b/api/src/repository/vsp_repository.py @@ -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() diff --git a/api/src/services/vsp_service.py b/api/src/services/vsp_service.py index 63e7847..113ccbe 100644 --- a/api/src/services/vsp_service.py +++ b/api/src/services/vsp_service.py @@ -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, ) diff --git a/api/tests/integration/test_vsp.py b/api/tests/integration/test_vsp.py index 4ea505c..d92cc44 100644 --- a/api/tests/integration/test_vsp.py +++ b/api/tests/integration/test_vsp.py @@ -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 diff --git a/api/tests/unit/test_vsp_status.py b/api/tests/unit/test_vsp_status.py new file mode 100644 index 0000000..0dd6cbb --- /dev/null +++ b/api/tests/unit/test_vsp_status.py @@ -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