api: загрузка файлов в консолидацию, переход версий «в работе»; query версии у загрузки ЮЛ удалён (#96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 01:31:35 +04:00
parent 956a730a3c
commit 1e888d4670
7 changed files with 254 additions and 117 deletions

View File

@ -7,13 +7,14 @@ legal-entities/{legal_entity_id}/files` — рабочий стол консол
import uuid import uuid
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, status from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.files import PrimaryFilesServiceDep from src.api.dependencies.files import PrimaryFilesServiceDep
from src.api.endpoints.files.read import FileListQuery from src.api.endpoints.files.read import FileListQuery
from src.api.endpoints.files.upload import accept_uploads
from src.schemas.common import ErrorDetail, Page, PageParams from src.schemas.common import ErrorDetail, Page, PageParams
from src.schemas.files import FileOut from src.schemas.files import FileOut, UploadResult
router = APIRouter() router = APIRouter()
@ -44,3 +45,40 @@ async def list_desk_files(
consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page
) )
return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset)
UPLOAD_RESPONSES: dict[int | str, dict[str, Any]] = {
**NOT_FOUND,
status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_NOT_EDITABLE — нет версии в работе"},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"model": UploadResult,
"description": "ни один файл не принят; ЮЛ вне периметра версий в работе (ENTITY_NOT_IN_PERIMETER)",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
}
@router.post(
DESK_PATH,
operation_id="upload_consolidation_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
response_model=UploadResult,
status_code=status.HTTP_201_CREATED,
dependencies=EDIT,
responses=UPLOAD_RESPONSES,
summary="Загрузка файлов ЮЛ в консолидацию",
)
async def upload_consolidation_files(
consolidation_id: uuid.UUID,
legal_entity_id: uuid.UUID,
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
service: PrimaryFilesServiceDep,
response: Response,
) -> UploadResult:
"""До приёма: консолидация и ЮЛ видимы, есть версия «в работе», ЮЛ в её периметре. Принятые файлы получают
`consolidation_id`; после приёма хотя бы одного версии `INITIATED` с этим ЮЛ → `DOCUMENTS_PROCESSING`."""
entity = await service.get_entity(legal_entity_id)
consolidation = await service.check_consolidation_for_upload(consolidation_id, entity)
result = await accept_uploads(service, entity, files, response, consolidation_id=consolidation.id)
if result.accepted:
await service.after_upload_to_consolidation(consolidation.id, entity.id)
return result

View File

@ -1,5 +1,6 @@
"""`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов """`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов
организации», «Дедуп по содержимому»; design D-4). организации», «Дедуп по содержимому»; design D-4). Загрузка в консолидацию — `POST /consolidations/{id}/legal-entities/
{id}/files` (`consolidation_files.py`), общий приём файлов — `accept_uploads`.
multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404. multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404.
Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный
@ -10,59 +11,30 @@ multipart: одно или несколько полей `files`; право `DO
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.files import PrimaryFilesServiceDep from src.api.dependencies.files import PrimaryFilesServiceDep
from src.common.exceptions import DomainError from src.common.exceptions import DomainError
from src.common.filenames import read_limited, safe_filename from src.common.filenames import read_limited, safe_filename
from src.models.directories import LegalEntity
from src.schemas.common import ErrorDetail from src.schemas.common import ErrorDetail
from src.schemas.files import AcceptedOut, RejectedOut, UploadResult from src.schemas.files import AcceptedOut, RejectedOut, UploadResult
from src.services.primary_files import FILE_TOO_LARGE, RejectedFile from src.services.primary_files import FILE_TOO_LARGE, PrimaryFilesService, RejectedFile
router = APIRouter() router = APIRouter()
@router.post( async def accept_uploads(
"/legal-entities/{legal_entity_id}/files", service: PrimaryFilesService,
operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes) entity: LegalEntity,
response_model=UploadResult, files: list[UploadFile],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require("DOCUMENT_EDIT"))],
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorDetail,
"description": "ЮЛ нет или оно вне видимости; версия консолидации не найдена или чужая (VERSION_NOT_FOUND)",
},
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "версия консолидации не в работе (VERSION_NOT_EDITABLE)",
},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"model": UploadResult,
"description": "ни один файл не принят; ЮЛ вне периметра версии (ENTITY_NOT_IN_PERIMETER)",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
},
summary="Загрузка файлов организации",
)
async def upload_files(
legal_entity_id: uuid.UUID,
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
service: PrimaryFilesServiceDep,
response: Response, response: Response,
consolidation_version_id: Annotated[ *,
uuid.UUID | None, consolidation_id: uuid.UUID | None,
Query(description="Версия консолидации, в контексте которой загружаются файлы (необязательно)"),
] = None,
) -> UploadResult: ) -> UploadResult:
"""Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля). """Общий приём файлов двух маршрутов загрузки: каждый файл принимается или отклоняется отдельно; 422 — если не
принят ни один. `consolidation_id` пишется в `stored_file` (загрузка в консолидацию) или `None`."""
С `consolidation_version_id`: до приёма — версия видима, «в работе», ЮЛ в периметре; после приёма хотя бы одного
файла версия `INITIATED` → `DOCUMENTS_PROCESSING` (отдельная транзакция, блокировка версии не нужна)."""
entity = await service.get_entity(legal_entity_id)
if consolidation_version_id is not None:
await service.check_version_for_upload(consolidation_version_id, entity)
service.require_storage() service.require_storage()
accepted: list[AcceptedOut] = [] accepted: list[AcceptedOut] = []
rejected: list[RejectedOut] = [] rejected: list[RejectedOut] = []
@ -78,7 +50,7 @@ async def upload_files(
) )
continue continue
try: try:
result = await service.upload_one(entity, name, content) result = await service.upload_one(entity, name, content, consolidation_id=consolidation_id)
except DomainError as error: except DomainError as error:
failure = RejectedFile.from_error(safe_filename(name, default=""), error) failure = RejectedFile.from_error(safe_filename(name, default=""), error)
rejected.append( rejected.append(
@ -88,6 +60,28 @@ async def upload_files(
accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name))
if not accepted: if not accepted:
response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
elif consolidation_version_id is not None:
await service.after_upload_to_version(consolidation_version_id)
return UploadResult(accepted=accepted, rejected=rejected) return UploadResult(accepted=accepted, rejected=rejected)
@router.post(
"/legal-entities/{legal_entity_id}/files",
operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
response_model=UploadResult,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require("DOCUMENT_EDIT"))],
responses={
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"},
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
},
summary="Загрузка файлов организации",
)
async def upload_files(
legal_entity_id: uuid.UUID,
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
service: PrimaryFilesServiceDep,
response: Response,
) -> UploadResult:
"""Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля)."""
entity = await service.get_entity(legal_entity_id)
return await accept_uploads(service, entity, files, response, consolidation_id=None)

View File

@ -179,6 +179,42 @@ async def lock_versions_for_entity(
return list((await session.execute(members_only)).scalars()) return list((await session.execute(members_only)).scalars())
async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]:
"""Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3)."""
stmt = (
select(ConsolidationVersion)
.where(ConsolidationVersion.consolidation_id == consolidation_id)
.order_by(ConsolidationVersion.version_no)
)
return list((await session.execute(stmt)).scalars())
async def lock_versions_of_consolidation(
session: AsyncSession,
consolidation_id: uuid.UUID,
entity_id: uuid.UUID,
statuses: Collection[ConsolidationStatus],
) -> list[ConsolidationVersion]:
"""Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2;
design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД."""
is_member = exists().where(
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
ConsolidationMember.legal_entity_id == entity_id,
)
stmt = (
select(ConsolidationVersion)
.where(
ConsolidationVersion.consolidation_id == consolidation_id,
ConsolidationVersion.status.in_(statuses),
is_member,
)
.order_by(ConsolidationVersion.id)
.with_for_update()
.execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars())
async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None: async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None:
"""Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE).""" """Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE)."""
stmt = select(ConsolidationLock).where( stmt = select(ConsolidationLock).where(

View File

@ -8,8 +8,8 @@ audit-trail «События файлов и документов»; design D-4)
1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3; 1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3;
2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется); 2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется);
3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся); 3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся);
4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`, 4. `stored_file` (`UPLOADED`, `version_no = 1`, `consolidation_id` при загрузке в консолидацию) и `recognition_job`
`stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`. (`queued`) + `flush`, `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD` (с `consolidation_id`).
Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи
`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован; `s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован;
@ -37,7 +37,7 @@ from src.core.config import Settings
from src.core.storage import ObjectNotFoundError, ObjectStorage from src.core.storage import ObjectNotFoundError, ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
from src.models.consolidation import ConsolidationVersion from src.models.consolidation import Consolidation
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
AuditAction, AuditAction,
@ -56,7 +56,7 @@ from src.schemas.common import PageParams
from src.schemas.files import FileOut from src.schemas.files import FileOut
from src.services import consolidation_documents from src.services import consolidation_documents
from src.services.audit import AuditService from src.services.audit import AuditService
from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND, VERSION_NOT_FOUND from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND
from src.services.documents import file_out from src.services.documents import file_out
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
from src.services.result_mapping import RESULTS_PREFIX from src.services.result_mapping import RESULTS_PREFIX
@ -160,34 +160,35 @@ class PrimaryFilesService:
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
return entity return entity
async def check_version_for_upload(self, version_id: uuid.UUID, entity: LegalEntity) -> ConsolidationVersion: async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation:
"""Загрузка в контексте версии консолидации (spec primary-files «Загрузка файлов организации», D-6): ДО приёма """Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма —
файлов версия видима (иначе 404 `VERSION_NOT_FOUND`) → статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) → консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`)
ЮЛ — член периметра (иначе 422 `ENTITY_NOT_IN_PERIMETER`). Блокировка версии (`consolidation_lock`) и → ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус
`FOR UPDATE` не нужны: статус перепроверяется под блокировкой в `after_upload_to_version`.""" перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`."""
found = await consolidations_repo.get_version(self._session, self._scope, version_id) consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id)
if found is None: if consolidation is None:
raise NotFoundError(VERSION_NOT_FOUND) raise NotFoundError(CONSOLIDATION_NOT_FOUND)
version = found[0] in_work = [
if version.status not in IN_WORK_STATUSES: version
raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется") for version in await consolidations_repo.versions_of(self._session, consolidation.id)
members = await consolidations_repo.members(self._session, version.id) if version.status in IN_WORK_STATUSES
if all(member.legal_entity_id != entity.id for member, _ in members): ]
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версии консолидации") if not in_work:
return version raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе")
for version in in_work:
members = await consolidations_repo.members(self._session, version.id)
if any(member.legal_entity_id == entity.id for member, _ in members):
return consolidation
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе")
async def after_upload_to_version(self, version_id: uuid.UUID) -> None: async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None:
"""После приёма хотя бы одного файла: версия `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший). """После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом
`INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4).
В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан —
перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка
блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается."""
(D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет. # импорт внутри функции — модули консолидации импортируют друг друга
Приём файлов важнее побочного статуса (R-10, design №43): любая ошибка перехода (`Exception`) логируется и
НЕ меняет ответ — принятые файлы остаются, версия остаётся `INITIATED` до следующей загрузки или
подтверждения. `BaseException` (`asyncio.CancelledError`) не перехватывается."""
# импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга
from src.services.consolidation_status import transition # noqa: PLC0415 from src.services.consolidation_status import transition # noqa: PLC0415
factory = self._transaction factory = self._transaction
@ -197,25 +198,28 @@ class PrimaryFilesService:
factory = session_transaction factory = session_transaction
try: try:
async with factory() as session: async with factory() as session:
version = await consolidations_repo.lock_version(session, version_id) versions = await consolidations_repo.lock_versions_of_consolidation(
if version is None or version.status is not ConsolidationStatus.INITIATED: session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED}
return
await transition(
session,
AuditService(session),
version=version,
to=ConsolidationStatus.DOCUMENTS_PROCESSING,
changed_by=self._audit.user_id,
comment=None,
ip_address=self._audit.ip_address,
user_agent=self._audit.user_agent,
now=utc_now(),
) )
audit = AuditService(session)
now = utc_now()
for version in versions:
await transition(
session,
audit,
version=version,
to=ConsolidationStatus.DOCUMENTS_PROCESSING,
changed_by=self._audit.user_id,
comment=None,
ip_address=self._audit.ip_address,
user_agent=self._audit.user_agent,
now=now,
)
except Exception: except Exception:
logger.exception( logger.exception(
"Не удалось перевести версию в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " "Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. "
"error_category=version_transition_error, version_id={version_id}", "error_category=version_transition_error, consolidation_id={consolidation_id}",
version_id=version_id, consolidation_id=consolidation_id,
) )
def require_storage(self) -> ObjectStorage: def require_storage(self) -> ObjectStorage:
@ -386,7 +390,9 @@ class PrimaryFilesService:
) )
return (await self._session.execute(stmt)).scalar_one_or_none() return (await self._session.execute(stmt)).scalar_one_or_none()
async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile: async def upload_one(
self, entity: LegalEntity, name: str, content: bytes, *, consolidation_id: uuid.UUID | None = None
) -> AcceptedFile:
"""Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`, """Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT.""" `STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT."""
storage = self.require_storage() storage = self.require_storage()
@ -403,7 +409,13 @@ class PrimaryFilesService:
file_id=str(duplicate.id), file_id=str(duplicate.id),
) )
return await self._store( return await self._store(
entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected entity,
name=safe_name,
content=content,
sha256=sha256,
storage=storage,
detected=detected,
consolidation_id=consolidation_id,
) )
async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None: async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None:
@ -421,6 +433,7 @@ class PrimaryFilesService:
sha256: str, sha256: str,
storage: ObjectStorage, storage: ObjectStorage,
detected: DetectedFile, detected: DetectedFile,
consolidation_id: uuid.UUID | None,
) -> AcceptedFile: ) -> AcceptedFile:
now = utc_now() now = utc_now()
user_id = self._audit.user_id user_id = self._audit.user_id
@ -452,6 +465,7 @@ class PrimaryFilesService:
stored_file = StoredFile( stored_file = StoredFile(
id=file_id, id=file_id,
legal_entity_id=entity.id, legal_entity_id=entity.id,
consolidation_id=consolidation_id,
file_type=detected.file_type, file_type=detected.file_type,
original_name=name, original_name=name,
s3_object_id=s3_object.id, s3_object_id=s3_object.id,
@ -491,6 +505,7 @@ class PrimaryFilesService:
"content_hash": sha256, "content_hash": sha256,
"job_id": str(job_id), "job_id": str(job_id),
"file_type": detected.file_type.value, "file_type": detected.file_type.value,
"consolidation_id": str(consolidation_id) if consolidation_id is not None else None,
}, },
) )
return AcceptedFile(file_id=file_id, job_id=job_id, name=name) return AcceptedFile(file_id=file_id, job_id=job_id, name=name)

View File

@ -25,7 +25,7 @@ from src.api.dependencies.directories import get_app_settings
from src.core.config import get_settings from src.core.config import get_settings
from src.models.access import AppUser from src.models.access import AppUser
from src.models.audit_ex import AuditEvent from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationStatusHistory, ConsolidationVersion
from src.models.directories import LegalEntity, RegionalOffice from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import ( from src.models.enums import (
AuditAction, AuditAction,
@ -41,7 +41,7 @@ from src.models.enums import (
from src.models.files import RecognitionJob, S3Object, StoredFile from src.models.files import RecognitionJob, S3Object, StoredFile
from src.services.recognition_exchange import publish_pending from src.services.recognition_exchange import publish_pending
from tests.conftest import AuthClientFactory, FakeStorage from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock, make_version
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
HTTP_CREATED = 201 HTTP_CREATED = 201
@ -311,6 +311,7 @@ async def test_two_files_write_two_upload_events(
"content_hash": hashlib.sha256(content).hexdigest(), "content_hash": hashlib.sha256(content).hexdigest(),
"job_id": item["job_id"], "job_id": item["job_id"],
"file_type": file_type, "file_type": file_type,
"consolidation_id": None,
} }
assert len(storage.objects) == len(accepted) assert len(storage.objects) == len(accepted)
@ -578,26 +579,33 @@ async def _version_state(session: AsyncSession, version_id: uuid.UUID) -> Consol
return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one() return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one()
async def _post_in_version( async def _post_in_consolidation(
client: httpx.AsyncClient, entity_id: uuid.UUID, version_id: uuid.UUID, *uploads: Upload client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload
) -> httpx.Response: ) -> httpx.Response:
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
return await client.post(_files_url(entity_id), params={"consolidation_version_id": str(version_id)}, files=files) return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files)
@pytest.mark.integration @pytest.mark.integration
async def test_upload_in_version_moves_initiated_to_documents_processing( async def test_upload_in_consolidation_moves_initiated_to_documents_processing(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None: ) -> None:
version, (member, *_), _ = await _version(seeded) version, (member, *_), _ = await _version(seeded)
user_id = await _user_id(seeded, KA_RF) user_id = await _user_id(seeded, KA_RF)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("in-version"))) response = await _post_in_consolidation(
client, version.consolidation_id, member.id, ("osv.xls", _xls("in-version"))
)
assert response.status_code == HTTP_CREATED, response.text assert response.status_code == HTTP_CREATED, response.text
assert len(response.json()["accepted"]) == 1 assert len(response.json()["accepted"]) == 1
assert len(storage.objects) == 1 assert len(storage.objects) == 1
file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"]))
assert file is not None
assert file.consolidation_id == version.consolidation_id
[upload_event] = await _upload_events(seeded)
assert upload_event.after_value["consolidation_id"] == str(version.consolidation_id)
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
history = ( history = (
( (
@ -627,13 +635,15 @@ async def test_upload_in_version_moves_initiated_to_documents_processing(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION]) @pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION])
async def test_upload_in_version_keeps_other_in_work_status( async def test_upload_in_consolidation_keeps_other_in_work_status(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus
) -> None: ) -> None:
version, (member, *_), _ = await _version(seeded, status=status) version, (member, *_), _ = await _version(seeded, status=status)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls(f"keep-{status.value}"))) response = await _post_in_consolidation(
client, version.consolidation_id, member.id, ("osv.xls", _xls(f"keep-{status.value}"))
)
assert response.status_code == HTTP_CREATED, response.text assert response.status_code == HTTP_CREATED, response.text
assert await _version_state(seeded, version.id) is status assert await _version_state(seeded, version.id) is status
@ -647,7 +657,7 @@ async def test_upload_with_all_files_rejected_does_not_move_version(
version, (member, *_), _ = await _version(seeded) version, (member, *_), _ = await _version(seeded)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await _post_in_version(client, member.id, version.id, ("notes.docx", ZIP + b"x")) response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("notes.docx", ZIP + b"x"))
assert response.status_code == HTTP_UNPROCESSABLE, response.text assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["accepted"] == [] assert response.json()["accepted"] == []
@ -664,7 +674,9 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing(
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
before = await _counts(seeded) before = await _counts(seeded)
response = await _post_in_version(client, outsider.id, version.id, ("osv.xls", _xls("outsider"))) response = await _post_in_consolidation(
client, version.consolidation_id, outsider.id, ("osv.xls", _xls("outsider"))
)
assert response.status_code == HTTP_UNPROCESSABLE, response.text assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER" assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER"
@ -674,23 +686,23 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing(
@pytest.mark.integration @pytest.mark.integration
async def test_upload_in_cancelled_version_is_409( async def test_upload_without_in_work_version_is_409(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None: ) -> None:
version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED) version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
before = await _counts(seeded) before = await _counts(seeded)
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("cancelled"))) response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("osv.xls", _xls("cancelled")))
assert response.status_code == HTTP_CONFLICT, response.text assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" assert response.json()["detail"]["code"] == "CONSOLIDATION_NOT_EDITABLE"
assert await _counts(seeded) == before assert await _counts(seeded) == before
assert storage.objects == {} assert storage.objects == {}
@pytest.mark.integration @pytest.mark.integration
async def test_upload_in_foreign_version_is_404_like_missing( async def test_upload_in_foreign_consolidation_is_404_like_missing(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None: ) -> None:
foreign, _, _ = await _version(seeded, office_name=KRASNODAR) foreign, _, _ = await _version(seeded, office_name=KRASNODAR)
@ -698,8 +710,12 @@ async def test_upload_in_foreign_version_is_404_like_missing(
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
before = await _counts(seeded) before = await _counts(seeded)
foreign_response = await _post_in_version(client, own_entity.id, foreign.id, ("osv.xls", _xls("foreign-version"))) foreign_response = await _post_in_consolidation(
missing_response = await _post_in_version(client, own_entity.id, uuid.uuid4(), ("osv.xls", _xls("missing-version"))) client, foreign.consolidation_id, own_entity.id, ("osv.xls", _xls("foreign-version"))
)
missing_response = await _post_in_consolidation(
client, uuid.uuid4(), own_entity.id, ("osv.xls", _xls("missing-version"))
)
assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text
assert foreign_response.json() == missing_response.json() assert foreign_response.json() == missing_response.json()
@ -718,7 +734,9 @@ async def test_upload_does_not_require_version_lock(
await make_lock(seeded, version=version, user=holder) await make_lock(seeded, version=version, user=holder)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("under-lock"))) response = await _post_in_consolidation(
client, version.consolidation_id, member.id, ("osv.xls", _xls("under-lock"))
)
assert response.status_code == HTTP_CREATED, response.text assert response.status_code == HTTP_CREATED, response.text
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
@ -726,17 +744,28 @@ async def test_upload_does_not_require_version_lock(
@pytest.mark.integration @pytest.mark.integration
async def test_upload_without_version_does_not_touch_versions( async def test_upload_without_consolidation_does_not_touch_versions(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None: ) -> None:
version, (member, *_), _ = await _version(seeded) version, (member, *_), _ = await _version(seeded)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await _post(client, member.id, ("osv.xls", _xls("no-version"))) response = await _post(client, member.id, ("osv.xls", _xls("no-version")))
ignored = await client.post(
_files_url(member.id),
params={"consolidation_version_id": str(version.id)},
files=[("files", ("osv.xls", _xls("ignored-query"), "application/octet-stream"))],
)
assert response.status_code == HTTP_CREATED, response.text assert response.status_code == HTTP_CREATED, response.text
assert ignored.status_code == HTTP_CREATED, ignored.text
bodies = (response.json(), ignored.json())
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(storage.objects) == 1 assert len(storage.objects) == len(bodies)
for body in bodies:
file = await seeded.get(StoredFile, uuid.UUID(body["accepted"][0]["file_id"]))
assert file is not None
assert file.consolidation_id is None
@pytest.mark.integration @pytest.mark.integration
@ -755,7 +784,9 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails(
messages: list[str] = [] messages: list[str] = []
sink = logger.add(lambda message: messages.append(str(message)), level="ERROR") sink = logger.add(lambda message: messages.append(str(message)), level="ERROR")
try: try:
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails"))) response = await _post_in_consolidation(
client, version.consolidation_id, member.id, ("osv.xls", _xls("transition-fails"))
)
finally: finally:
logger.remove(sink) logger.remove(sink)
@ -764,4 +795,26 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails(
assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None
assert len(storage.objects) == 1 assert len(storage.objects) == 1
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
assert any(str(version.id) in message and "version_transition_error" in message for message in messages) assert any(
str(version.consolidation_id) in message and "version_transition_error" in message for message in messages
)
@pytest.mark.integration
async def test_upload_moves_only_versions_with_entity(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4)."""
version, entities, initiator = await _version(seeded)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
other = await make_version(
seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator
)
client = await auth_client(KA_RF)
response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2")))
assert response.status_code == HTTP_CREATED, response.text
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING

View File

@ -1159,7 +1159,7 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member(
async def test_on_documents_mapped_waits_for_transition_to_documents_processing( async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None: ) -> None:
"""R-2/№40, маппинг ∥ загрузка (`after_upload_to_version`): версия `INITIATED` держится транзакцией, которая """R-2/№40, маппинг ∥ загрузка (`after_upload_to_consolidation`): версия `INITIATED` держится транзакцией, которая
переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только
`DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` — `DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` —
переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED` переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED`

View File

@ -49,6 +49,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
f"{A}/roles/{{code}}/permissions": {"put"}, f"{A}/roles/{{code}}/permissions": {"put"},
# primary-files, documents-api (Срез 2c) # primary-files, documents-api (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
"/api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files": {"get", "post"}, # v5.18
"/api/v1/files/{file_id}": {"get", "delete"}, "/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/files/{file_id}/content": {"get"}, "/api/v1/files/{file_id}/content": {"get"},
"/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}": {"get"},