diff --git a/api/alembic/versions/0004_vsp_delete_with_close_date.py b/api/alembic/versions/0004_vsp_delete_with_close_date.py new file mode 100644 index 0000000..309685a --- /dev/null +++ b/api/alembic/versions/0004_vsp_delete_with_close_date.py @@ -0,0 +1,63 @@ +from alembic import op + + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.del_vsp(p_vsp_id integer, p_deleted_by integer) + RETURNS integer + LANGUAGE plpgsql + AS $function$ + DECLARE + v_old RECORD; + v_usr RECORD; + BEGIN + SELECT vsp.id, vsp.branch_id + INTO v_old + FROM v3.vsp vsp WHERE vsp.id = p_vsp_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'vsp #% не существует', p_vsp_id; + END IF; + SELECT usr.id + INTO v_usr + FROM v3.app_user usr WHERE usr.id = p_deleted_by; + IF NOT FOUND THEN + RAISE EXCEPTION 'user #% не существует', p_deleted_by; + END IF; + + UPDATE v3.vsp + SET is_deleted = true, + is_active = false, + updated_by = p_deleted_by + WHERE id = p_vsp_id; + UPDATE v3.vsp + SET closed_at = CURRENT_DATE + WHERE id = p_vsp_id + AND closed_at IS NULL; + + PERFORM v3.log_event( + 'DELETE_VSP', 'VSP', + jsonb_build_object( + 'vsp_id', p_vsp_id, + 'entity_id', p_vsp_id, + 'branch_id', v_old.branch_id, + 'updated_by', p_deleted_by + ), + null, null, v_old.branch_id + ); + + RETURN p_vsp_id; + END; + $function$; + """ + ) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0002_functions.sql b/api/alembic/versions/sql/0002_functions.sql index 119d335..ac01682 100644 --- a/api/alembic/versions/sql/0002_functions.sql +++ b/api/alembic/versions/sql/0002_functions.sql @@ -1739,7 +1739,6 @@ AS $function$ DECLARE v_old RECORD; v_usr RECORD; - v_closed DATE; BEGIN SELECT vsp.id, vsp.branch_id INTO v_old @@ -1755,18 +1754,15 @@ BEGIN END IF; - UPDATE v3.vsp SET is_deleted = true, updated_by = p_deleted_by WHERE id = p_vsp_id; - UPDATE v3.vsp SET closed_at = CURRENT_DATE - WHERE id = p_vsp_id AND closed_at IS NULL - RETURNING closed_at INTO v_closed; - - IF v_closed IS NULL THEN - IF EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN - RAISE EXCEPTION 'vsp #% уже закрыт', p_vsp_id; - ELSE - RAISE EXCEPTION 'vsp #% не существует', p_vsp_id; - END IF; - END IF; + UPDATE v3.vsp + SET is_deleted = true, + is_active = false, + updated_by = p_deleted_by + WHERE id = p_vsp_id; + UPDATE v3.vsp + SET closed_at = CURRENT_DATE + WHERE id = p_vsp_id + AND closed_at IS NULL; PERFORM v3.log_event( 'DELETE_VSP', 'VSP', diff --git a/api/src/services/vsp_service.py b/api/src/services/vsp_service.py index 6642e3f..03906c0 100644 --- a/api/src/services/vsp_service.py +++ b/api/src/services/vsp_service.py @@ -11,6 +11,7 @@ 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.repository.org_unit_repository import OrgUnitRepository from src.repository.user_repository import UserRepository from src.repository.vsp_repository import VSPRepository @@ -31,6 +32,7 @@ class VSPService: self.db = db self.vsp_repo = VSPRepository(db) self.form_repo = BudgetFormRepository(db) + self.org_unit_repo = OrgUnitRepository(db) self.user_repo = UserRepository(db) async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]: @@ -128,6 +130,7 @@ class VSPService: async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp: if not self._can_modify(user): raise AccessDeniedException() + await self._ensure_active_branch(payload.branch_id) try: created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit) if created.closed_at is not None and created.closed_at <= date.today(): @@ -245,6 +248,9 @@ class VSPService: if not data: return vsp + if "branch_id" in data: + await self._ensure_active_branch(data["branch_id"]) + if user.role_id != UserRoleEnum.ADMIN: ssp_ids = await self._get_scoped_ssp_ids(user=user) if not ssp_ids or vsp.branch_id not in set(ssp_ids): @@ -291,3 +297,10 @@ class VSPService: return await self.vsp_repo.deactivate_by_close_date(as_of=today) _last_info_close_sync_run = today + + async def _ensure_active_branch(self, branch_id: int | None) -> None: + if branch_id is None: + return + org_unit = await self.org_unit_repo.get(org_unit_id=branch_id) + if org_unit is None or not org_unit.is_active: + raise ValidationException(description="Указан неактивный или отсутствующий ССП/РФ") diff --git a/api/tests/integration/test_vsp.py b/api/tests/integration/test_vsp.py index 7fd80f6..3d926a6 100644 --- a/api/tests/integration/test_vsp.py +++ b/api/tests/integration/test_vsp.py @@ -150,6 +150,36 @@ def test_vsp_create_forbidden_for_executor(client, isp_tokens, auth_headers): assert "message" in payload +def test_vsp_create_with_inactive_ssp_fails(client, admin_tokens, auth_headers): + created_org = client.post( + "/api/v1/org-unit/", + json={"title": f"Inactive SSP {uuid.uuid4().hex[:6]}", "is_ssp": True}, + headers=auth_headers(admin_tokens), + ) + assert created_org.status_code == 200 + org_unit_id = created_org.json()["result"]["id"] + + deactivate = client.patch( + f"/api/v1/org-unit/{org_unit_id}", + json={"is_active": False}, + headers=auth_headers(admin_tokens), + ) + assert deactivate.status_code == 200 + + response = client.post( + "/api/v1/dict/info", + json={ + "registration_number": _unique_reg_number(), + "address": "Should Fail", + "ssp_id": org_unit_id, + }, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert "message" in payload + + def test_vsp_update_smoke(client, admin_tokens, auth_headers): reg_number = _unique_reg_number() created = client.post( @@ -195,7 +225,33 @@ def test_vsp_delete_smoke(client, admin_tokens, auth_headers): json={ "registration_number": reg_number, "address": "To Be Deleted", - "ssp_id": 1, + "ssp_id": 2, + }, + headers=auth_headers(admin_tokens), + ) + assert created.status_code == 201 + vsp_id = created.json()["result"]["id"] + + response = client.delete( + f"/api/v1/dict/info/{vsp_id}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["message"] == "Запись INFO удалена" + + +def test_vsp_delete_with_close_date_smoke(client, admin_tokens, auth_headers): + reg_number = _unique_reg_number() + created = client.post( + "/api/v1/dict/info", + json={ + "registration_number": reg_number, + "address": "To Be Deleted with close_date", + "ssp_id": 2, + "open_date": "2026-01-01", + "close_date": "2030-01-01", }, headers=auth_headers(admin_tokens), )