"""Ручные новые попытки tabular; очередь и исходный объект S3 переиспользуются.""" import uuid from typing import Any from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, NotFoundError from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings, get_settings from src.domain.policy import IN_WORK_STATUSES, VisibilityScope from src.models.consolidation import ConsolidationMember, ConsolidationVersion from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus from src.models.files import Document, RecognitionJob, StoredFile from src.repositories import consolidations as versions_repo from src.repositories import files as files_repo from src.schemas.files import FileOut, RetryAccepted, RetryBatchOut, RetrySkipped from src.services.documents import file_out class FileRetryService: def __init__( self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, settings: Settings | None = None ) -> None: self.session, self.audit, self.scope = session, audit, scope self.settings = settings if settings is not None else get_settings() async def _lock_files(self, files: list[StoredFile]) -> list[tuple[StoredFile, RecognitionJob | None]]: expected = {file.id: file.current_recognition_job_id for file in files} jobs = list( ( await self.session.scalars( select(RecognitionJob) .where(RecognitionJob.id.in_([jid for jid in expected.values() if jid is not None])) .order_by(RecognitionJob.id) .with_for_update() .execution_options(populate_existing=True) ) ).all() ) by_id = {job.id: job for job in jobs} locked = list( ( await self.session.scalars( select(StoredFile) .where(StoredFile.id.in_(expected)) .order_by(StoredFile.id) .with_for_update() .execution_options(populate_existing=True) ) ).all() ) return [ ( file, by_id.get(file.current_recognition_job_id) if file.current_recognition_job_id is not None and file.current_recognition_job_id == expected[file.id] else None, ) for file in locked ] async def _reason(self, file: StoredFile, job: RecognitionJob | None) -> str | None: if ( job is None or file.status is not FileStatus.ERROR or job.pipeline is not RecognitionPipeline.tabular or (job.status, job.error_code) not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")} or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id))) ): return "FILE_NOT_RETRYABLE" if job.attempt >= job.max_attempts: return "MAX_ATTEMPTS_REACHED" return None async def _new(self, file: StoredFile, job: RecognitionJob, consolidation_id: uuid.UUID | None) -> RecognitionJob: now = utc_now() new = RecognitionJob( file_id=file.id, pipeline=job.pipeline, request_subject=request_subject(job.pipeline.value, self.settings.OPENBAO.NATS.request_subjects()), schema_version=CONTRACT_VERSION, status=RecognitionStatus.queued, attempt=job.attempt + 1, max_attempts=job.max_attempts, requested_at=now, ) self.session.add(new) await self.session.flush() file.current_recognition_job_id = new.id file.status, file.updated_at = FileStatus.UPLOADED, now after: dict[str, Any] = {"job_id": str(new.id), "attempt": new.attempt} if consolidation_id is not None: after["consolidation_id"] = str(consolidation_id) await self.audit.record( action=AuditAction.FILE_REPROCESS, section=AuditSection.PRIMARY_DOCUMENTS, object_type="stored_file", object_id=file.id, before_value={ "job_id": str(job.id), "attempt": job.attempt, "status": job.status.value, "error_code": job.error_code, }, after_value=after, ) return new async def retry(self, file_id: uuid.UUID) -> FileOut: visible = await files_repo.get_file(self.session, self.scope, file_id) if visible is None: raise NotFoundError("файл не найден") locked = await self._lock_files([visible]) if not locked: raise NotFoundError("файл не найден") file, job = locked[0] reason = await self._reason(file, job) if reason: raise ConflictError(reason, "Повторная обработка файла недоступна") if file.consolidation_id is not None: versions = list( ( await self.session.scalars( select(ConsolidationVersion) .where( ConsolidationVersion.consolidation_id == file.consolidation_id, ConsolidationVersion.status.in_(IN_WORK_STATUSES), ) .order_by(ConsolidationVersion.id) .with_for_update(read=True) .execution_options(populate_existing=True) ) ).all() ) allowed = [v.id for v in versions if v.status in IN_WORK_STATUSES] member = await self.session.scalar( select( exists().where( ConsolidationMember.consolidation_version_id.in_(allowed), ConsolidationMember.legal_entity_id == file.legal_entity_id, ) ) ) if not member: raise ConflictError("VERSION_NOT_EDITABLE", "Нет версии в работе с организацией файла") assert job is not None new = await self._new(file, job, None) await self.session.flush() return file_out(file, new, []) async def retry_version(self, version_id: uuid.UUID) -> RetryBatchOut: found = await versions_repo.get_version(self.session, self.scope, version_id) if found is None: raise NotFoundError("версия не найдена") _, consolidation = found members = select(ConsolidationMember.legal_entity_id).where( ConsolidationMember.consolidation_version_id == version_id ) candidates = await files_repo.retry_candidates(self.session, self.scope, consolidation.id, version_id) locked = await self._lock_files(candidates) version = await versions_repo.lock_version(self.session, version_id) if version is None: raise NotFoundError("версия не найдена") if version.status not in IN_WORK_STATUSES: raise ConflictError("VERSION_NOT_EDITABLE", "Версия не в работе") current_members = set((await self.session.scalars(members)).all()) result = RetryBatchOut(accepted=[], skipped=[]) for file, job in locked: if ( file.legal_entity_id not in current_members or file.consolidation_id != consolidation.id or file.status is not FileStatus.ERROR ): continue reason = await self._reason(file, job) if reason: result.skipped.append(RetrySkipped(file_id=file.id, code=reason)) continue assert job is not None new = await self._new(file, job, consolidation.id) result.accepted.append(RetryAccepted(file_id=file.id, job_id=new.id, attempt=new.attempt)) return result