import asyncio import io from datetime import date, datetime from typing import Optional from openpyxl import Workbook from sqlalchemy.exc import IntegrityError 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.repository.user_repository import UserRepository from src.repository.vsp_repository import VSPRepository _last_info_close_sync_run: Optional[date] = None _info_close_sync_lock = asyncio.Lock() class VSPService: EXECUTOR_EDITABLE_FIELDS = { "location_form", "numbers", "total_area", "rent_contract_num", "rent_end_date", } def __init__(self, db: AsyncSession): self.db = db self.vsp_repo = VSPRepository(db) self.form_repo = BudgetFormRepository(db) self.user_repo = UserRepository(db) async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]: vsp = await self.vsp_repo.get(vsp_id=vsp_id, load_org_unit=load_org_unit) if not vsp: return None if user.role_id == UserRoleEnum.ADMIN: return vsp ssp_ids = await self._get_scoped_ssp_ids(user=user) if not ssp_ids or vsp.branch_id not in set(ssp_ids): raise AccessDeniedException() return vsp async def get_list( self, user: AppUser, registration_number: str | None = None, address: str | None = None, ssp_id: int | None = None, ssp_ids: list[int] | None = None, open_date_start: date | None = None, open_date_end: date | None = None, location_form: str | None = None, numbers_min: int | None = None, numbers_max: int | None = None, is_active: bool | None = None, load_org_unit: bool = False, with_count: bool = False, ) -> list[Vsp] | tuple[int, list[Vsp]]: await self._sync_closed_records() scoped_ssp_ids = await self._get_scoped_ssp_ids(user=user) requested_ssp_ids = list(dict.fromkeys(ssp_ids or [])) if ssp_id is not None and ssp_id not in requested_ssp_ids: requested_ssp_ids.append(ssp_id) effective_ssp_ids = scoped_ssp_ids if requested_ssp_ids: if scoped_ssp_ids is None: effective_ssp_ids = requested_ssp_ids else: scoped_set = set(scoped_ssp_ids) effective_ssp_ids = [item for item in requested_ssp_ids if item in scoped_set] if not effective_ssp_ids: if with_count: return 0, [] return [] return await self.vsp_repo.get_list( registration_number=registration_number, address=address, ssp_ids=effective_ssp_ids, open_date_start=open_date_start, open_date_end=open_date_end, location_form=location_form, numbers_min=numbers_min, numbers_max=numbers_max, is_active=is_active, load_org_unit=load_org_unit, with_count=with_count, ) async def get_dropdown( self, user: AppUser, ssp_id: int | None = None, form_id: int | None = None, with_count: bool = False, ) -> list[Vsp]: ssp_ids = await self._get_scoped_ssp_ids(user=user) checking_ssps = set() if ssp_id is not None: checking_ssps.add(ssp_id) if form_id is not None: form = await self.form_repo.get(budget_form_id=form_id) if form is not None: checking_ssps.add(form.org_unit_id) if checking_ssps: if ssp_ids is None: ssp_ids = list(checking_ssps) elif not checking_ssps & set(ssp_ids): if with_count: return 0, [] return [] else: ssp_ids = list(checking_ssps & set(ssp_ids)) return await self.vsp_repo.get_list( ssp_ids=ssp_ids, is_active=True, close_date_is_null=True, with_count=with_count, ) async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp: if not self._can_modify(user): raise AccessDeniedException() 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(): created = await self.vsp_repo.update( vsp=created, data={"is_active": False}, updated_by=user.id, load_org_unit=load_org_unit, ) return created except IntegrityError as exc: message = str(getattr(exc, "orig", exc) or exc).strip() if "vsp_reg_number_unique" in message: raise ValidationException(description="Указанный номер уже существует") if "chk_closed_after_opened" in message: raise ValidationException(description="Дата закрытия не может быть меньше даты открытия") raise ValidationException(description="Некорректные данные для записи ВСП") from exc async def export_xlsx( self, user: AppUser, registration_number: str | None = None, address: str | None = None, ssp_ids: list[int] | None = None, open_date_start: date | None = None, open_date_end: date | None = None, location_form: str | None = None, numbers_min: int | None = None, numbers_max: int | None = None, ) -> tuple[io.BytesIO, str]: data = await self.get_list( user=user, registration_number=registration_number, address=address, ssp_ids=ssp_ids, open_date_start=open_date_start, open_date_end=open_date_end, location_form=location_form, numbers_min=numbers_min, numbers_max=numbers_max, is_active=None, load_org_unit=True, ) workbook = Workbook() sheet = workbook.active sheet.title = "INFO" sheet.append( [ "id", "ssp_id", "system_code", "regional_branch", "vsp_type", "registration_number", "address", "approved_format", "open_date", "close_date", "notes", "location_form", "numbers", "area", "rent_contract_num", "rent_end_date", "is_active", "is_deleted", "created_at", "updated_at", "created_by", "updated_by", ] ) for item in data: sheet.append( [ item.id, item.branch_id, item.system_code or None, item.org_unit.title or None, item.vsp_type or None, item.reg_number, item.address, item.format or None, item.opened_at.isoformat() if item.opened_at else None, item.closed_at.isoformat() if item.closed_at else None, item.notes or None, item.location_form or None, item.numbers if item.numbers is not None else None, float(item.total_area) if item.total_area is not None else None, item.rent_contract_num or None, item.rent_end_date.isoformat() if item.rent_end_date else None, item.is_active, item.is_deleted, item.created_at.isoformat() if item.created_at else None, item.updated_at.isoformat() if item.updated_at else None, item.created_by, item.updated_by if item.updated_by is not None else None, ] ) stream = io.BytesIO() workbook.save(stream) workbook.close() stream.seek(0) file_name = f"info_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return stream, file_name async def update(self, vsp_id: int, payload: VSPUpdate, user: AppUser, load_org_unit: bool = False) -> Vsp | None: vsp = await self.vsp_repo.get(vsp_id=vsp_id, is_active=None) if not vsp: return None data = payload.model_dump(exclude_unset=True) if not data: return vsp 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): raise AccessDeniedException() if not set(data.keys()).issubset(self.EXECUTOR_EDITABLE_FIELDS): raise AccessDeniedException() effective_close_date = data.get("close_date", vsp.closed_at) if effective_close_date is not None and effective_close_date <= date.today(): data["is_active"] = False try: return await self.vsp_repo.update( vsp=vsp, data=data, updated_by=user.id, load_org_unit=load_org_unit, ) except IntegrityError as exc: message = str(getattr(exc, "orig", exc) or exc).strip() if "vsp_reg_number_unique" in message: raise ValidationException(description="Указанный номер уже существует") if "chk_closed_after_opened" in message: raise ValidationException(description="Дата закрытия не может быть меньше даты открытия") raise ValidationException(description="Некорректные данные для записи ВСП") from exc async def logical_delete(self, vsp_id: int, user: AppUser) -> bool: if not self._can_modify(user): raise AccessDeniedException() return await self.vsp_repo.logical_delete(vsp_id=vsp_id, updated_by=user.id) def _can_modify(self, user: AppUser) -> bool: return user.role_id == UserRoleEnum.ADMIN async def _get_scoped_ssp_ids(self, user: AppUser) -> list[int] | None: if user.role_id == UserRoleEnum.ADMIN: return None return list(set(await self.user_repo.get_many_ssp_ids(user_id=user.id))) async def _sync_closed_records(self) -> None: global _last_info_close_sync_run today = date.today() if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today: return async with _info_close_sync_lock: if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today: return await self.vsp_repo.deactivate_by_close_date(as_of=today) _last_info_close_sync_run = today