Срез 2c: загрузка файлов ОСВ, цикл маппинга результата в document/osv_entry, API документов, DBML v5.15 #15

Merged
aep merged 35 commits from feat/14-slice-2c-osv-ingest into main 2026-10-06 00:56:15 +03:00
16 changed files with 1215 additions and 61 deletions
Showing only changes of commit be0d1b410c - Show all commits

View File

@ -0,0 +1,25 @@
"""Зависимости API первичных файлов и документов (Срез 2c; R-6: сессия — только `SessionDep`)."""
from typing import Annotated
from fastapi import Depends, Request
from src.api.dependencies.audit import RequestAudit, get_request_audit
from src.api.dependencies.db import SessionDep
from src.api.dependencies.directories import SettingsDep, get_scope
from src.domain.policy import VisibilityScope
from src.services.primary_files import PrimaryFilesService
def get_primary_files_service(
request: Request,
session: SessionDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
scope: Annotated[VisibilityScope, Depends(get_scope)],
app_settings: SettingsDep,
) -> PrimaryFilesService:
"""Сервис первичных файлов; S3 — `app.state.storage` (`None`, если S3 недоступно или не подключено)."""
return PrimaryFilesService(session, audit, scope, getattr(request.app.state, "storage", None), app_settings)
PrimaryFilesServiceDep = Annotated[PrimaryFilesService, Depends(get_primary_files_service)]

View File

@ -10,22 +10,13 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile
from src.api.dependencies.auth import require
from src.api.dependencies.directories import DirectoryImportServiceDep, SettingsDep
from src.common.filenames import read_limited
from src.domain.directory_import import OnExisting
from src.schemas.directory_import import ImportReportOut
from src.services.directory_import import check_size
router = APIRouter()
async def _read_limited(file: UploadFile, limit: int) -> bytes:
"""Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`."""
if file.size is not None:
check_size(file.size, limit)
content = await file.read(limit + 1)
check_size(len(content), limit)
return content
@router.post(
"/import",
response_model=ImportReportOut,
@ -40,6 +31,6 @@ async def import_directory(
on_existing: Annotated[OnExisting, Form(description="Существующие ГК/ЮЛ: оставить или обновить")] = OnExisting.skip,
) -> ImportReportOut:
"""Отчёт: счётчики и построчные коды §13.2; при `dry_run = false` — запись в справочник, аудит и файл в S3."""
content = await _read_limited(file, settings.IMPORT_MAX_BYTES)
content = await read_limited(file, settings.IMPORT_MAX_BYTES)
report = await service.run(filename=file.filename or "", content=content, dry_run=dry_run, on_existing=on_existing)
return ImportReportOut.model_validate(report.to_dict())

View File

@ -0,0 +1,8 @@
"""Маршруты первичных файлов и документов (specs primary-files, documents-api)."""
from fastapi import APIRouter
from src.api.endpoints.files import upload
router = APIRouter(tags=["files"])
router.include_router(upload.router)

View File

@ -0,0 +1,72 @@
"""`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов
организации», «Дедуп по содержимому»; design D-4).
multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404.
Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный
успех): 201, если принят хотя бы один, иначе 422. Файл больше `UPLOAD_MAX_BYTES` отклоняется кодом `FILE_TOO_LARGE`
без чтения в память целиком; без S3 — 503 `STORAGE_UNAVAILABLE` на весь запрос.
"""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from src.api.dependencies.auth import require
from src.api.dependencies.files import PrimaryFilesServiceDep
from src.common.exceptions import DomainError
from src.common.filenames import read_limited, safe_filename
from src.schemas.common import ErrorDetail
from src.schemas.files import AcceptedOut, RejectedOut, UploadResult
from src.services.primary_files import FILE_TOO_LARGE, RejectedFile
router = APIRouter()
@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)
service.require_storage()
accepted: list[AcceptedOut] = []
rejected: list[RejectedOut] = []
for upload in files:
name = upload.filename or ""
try:
content = await read_limited(upload, service.max_upload_bytes)
except HTTPException as error:
if error.status_code != status.HTTP_413_CONTENT_TOO_LARGE:
raise
rejected.append(
RejectedOut(name=safe_filename(name, default=""), code=FILE_TOO_LARGE, message=error.detail)
)
continue
try:
result = await service.upload_one(entity, name, content)
except DomainError as error:
failure = RejectedFile.from_error(safe_filename(name, default=""), error)
rejected.append(
RejectedOut(name=failure.name, code=failure.code, message=failure.message, file_id=failure.file_id)
)
continue
accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name))
if not accepted:
response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
return UploadResult(accepted=accepted, rejected=rejected)

View File

@ -1,13 +1,13 @@
"""Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`).
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*` и `/admin/*` — всегда,
dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и
подключается в `src/app.py`.
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы
(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный
`router` собран по настройкам процесса и подключается в `src/app.py`.
"""
from fastapi import APIRouter
from src.api.endpoints import admin, directories, me
from src.api.endpoints import admin, directories, files, me
from src.api.endpoints.dev_auth import build_dev_router
from src.core.config import Settings, get_settings
@ -20,6 +20,7 @@ def build_api_router(settings: Settings) -> APIRouter:
api_router.include_router(me.router)
api_router.include_router(directories.router)
api_router.include_router(admin.router)
api_router.include_router(files.router)
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
api_router.include_router(build_dev_router(settings))
return api_router

View File

@ -0,0 +1,71 @@
"""Имена и размер загружаемых файлов: общее для импорта справочников и загрузки первичных файлов (Срез 2c, D-4).
`safe_filename` очищает имя, присланное клиентом, для аудита, БД и ключа S3; `CONTENT_TYPES` — MIME по расширению;
`read_limited` читает загрузку не больше лимита, превышение — `HTTPException` 413 строкой (как в импорте).
"""
import unicodedata
from pathlib import PurePosixPath
from fastapi import HTTPException, UploadFile, status
DEFAULT_FILENAME = "import"
MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/{uuid}/{uuid}/{имя}` остаётся в пределах 1024 байт
MAX_SUFFIX_LENGTH = 16
MEGABYTE = 1024 * 1024
CONTENT_TYPES = {
"csv": "text/csv",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
"pdf": "application/pdf",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
}
_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"})
def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> str:
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.
Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E,
нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена
`MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта.
Пустое имя (и `.`/`..`) — `default`: импорт подставляет `import`, загрузка первичных файлов — пустую строку
(такой файл отклоняется как `UNSUPPORTED_FILE`).
"""
cleaned = "".join(
char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES
)
name = PurePosixPath(cleaned).name.strip()
if name in {"", ".", ".."}:
return default
if len(name) > MAX_FILENAME_LENGTH:
suffix = PurePosixPath(name).suffix
suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else ""
name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix
return name
def extension(name: str) -> str:
"""Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка)."""
return PurePosixPath(name).suffix.lstrip(".").lower()
def check_size(size: int, limit: int) -> None:
"""413, если файл больше `limit` байт."""
if size > limit:
limit_text = f"{limit // MEGABYTE} МБ" if limit >= MEGABYTE else f"{limit} байт"
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit_text}")
async def read_limited(file: UploadFile, limit: int) -> bytes:
"""Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`."""
if file.size is not None:
check_size(file.size, limit)
content = await file.read(limit + 1)
check_size(len(content), limit)
return content

View File

@ -0,0 +1,74 @@
"""Тип загружаемого первичного файла (spec primary-files «Загрузка файлов организации»; design D-4).
Тип определяется по расширению имени и сверяется с сигнатурой содержимого: `xls` — составной документ OLE2 (BIFF8),
`xlsx`/`xlsb` — zip, `pdf`, `png`, `jpg`. Расширение, которого нет в списке, пустое имя или несовпадение сигнатуры —
`UNSUPPORTED_FILE`. Конвейер распознавания: таблицы → `tabular` (парсер, 3 попытки), изображения и PDF → `image`
(ИС Сканирование, 1 попытка — DBML `recognition_job.max_attempts`).
"""
from dataclasses import dataclass
from src.common.exceptions import DomainValidationError
from src.common.filenames import CONTENT_TYPES, extension
from src.models.enums import FileType, RecognitionPipeline
UNSUPPORTED_FILE = "UNSUPPORTED_FILE"
OLE2_SIGNATURE = b"\xd0\xcf\x11\xe0"
ZIP_SIGNATURE = b"PK\x03\x04"
PDF_SIGNATURE = b"%PDF"
PNG_SIGNATURE = b"\x89PNG"
JPG_SIGNATURE = b"\xff\xd8\xff"
FILE_TYPES: dict[str, FileType] = {
"xls": FileType.XLS,
"xlsx": FileType.XLSX,
"xlsb": FileType.XLSB,
"pdf": FileType.PDF,
"png": FileType.PNG,
"jpg": FileType.JPG,
"jpeg": FileType.JPG,
}
SIGNATURES: dict[FileType, bytes] = {
FileType.XLS: OLE2_SIGNATURE,
FileType.XLSX: ZIP_SIGNATURE,
FileType.XLSB: ZIP_SIGNATURE,
FileType.PDF: PDF_SIGNATURE,
FileType.PNG: PNG_SIGNATURE,
FileType.JPG: JPG_SIGNATURE,
}
PIPELINES: dict[FileType, RecognitionPipeline] = {
FileType.XLS: RecognitionPipeline.tabular,
FileType.XLSX: RecognitionPipeline.tabular,
FileType.XLSB: RecognitionPipeline.tabular,
FileType.PDF: RecognitionPipeline.image,
FileType.PNG: RecognitionPipeline.image,
FileType.JPG: RecognitionPipeline.image,
}
MAX_ATTEMPTS: dict[RecognitionPipeline, int] = {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1}
SUPPORTED_TEXT = "xls, xlsx, xlsb, pdf, png, jpg"
@dataclass(frozen=True)
class DetectedFile:
"""Тип файла, конвейер распознавания и MIME для `s3_object.content_type`."""
file_type: FileType
pipeline: RecognitionPipeline
content_type: str
def detect_file(name: str, content: bytes) -> DetectedFile:
"""Тип по расширению `name` (без учёта регистра), подтверждённый сигнатурой `content`; иначе `UNSUPPORTED_FILE`."""
suffix = extension(name)
file_type = FILE_TYPES.get(suffix)
if file_type is None:
raise DomainValidationError(
UNSUPPORTED_FILE, f"неподдерживаемый тип файла «{name}»: допустимы {SUPPORTED_TEXT}"
)
if not content.startswith(SIGNATURES[file_type]):
raise DomainValidationError(
UNSUPPORTED_FILE, f"содержимое файла «{name}» не соответствует расширению .{suffix}"
)
return DetectedFile(file_type=file_type, pipeline=PIPELINES[file_type], content_type=CONTENT_TYPES[suffix])

93
api/src/schemas/files.py Normal file
View File

@ -0,0 +1,93 @@
"""Схемы API первичных файлов (spec primary-files, documents-api; design D-4, D-11).
`UploadResult` — ответ загрузки (частичный успех: принятые и отклонённые файлы). `FileOut`/`JobBrief`/`DocumentBrief` —
карточка файла для чтения (Task 9 Среза 2c): статус файла, текущее задание с причиной ошибки и предупреждениями,
документы файла.
"""
import datetime
import uuid
from pydantic import BaseModel, ConfigDict
from src.models.enums import (
DocumentCheckResult,
DocumentStatus,
DocumentType,
FileStatus,
FileType,
PeriodQuarter,
RecognitionStatus,
)
class AcceptedOut(BaseModel):
"""Принятый файл: `stored_file.id`, `recognition_job.id` и очищенное имя."""
file_id: uuid.UUID
job_id: uuid.UUID
name: str
class RejectedOut(BaseModel):
"""Отклонённый файл: имя, код (`UNSUPPORTED_FILE`, `FILE_TOO_LARGE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`),
пояснение; `file_id` — существующий файл при `DUPLICATE_FILE`."""
name: str
code: str
message: str
file_id: uuid.UUID | None = None
class UploadResult(BaseModel):
"""Ответ `POST /legal-entities/{id}/files`: 201, если принят хотя бы один файл, иначе 422."""
accepted: list[AcceptedOut]
rejected: list[RejectedOut]
class JobBrief(BaseModel):
"""Текущее задание распознавания файла: статус, причина ошибки, предупреждения сервиса и маппинга."""
id: uuid.UUID
status: RecognitionStatus
error_code: str | None
error_message: str | None
warnings: list[str]
mapping_warnings: list[str]
finished_at: datetime.datetime | None
class DocumentBrief(BaseModel):
"""Документ файла в карточке файла."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
document_type: DocumentType
osv_account_code: str | None
period_year: int | None
period_quarter: PeriodQuarter | None
is_cumulative: bool | None
status: DocumentStatus
period_check: DocumentCheckResult | None
counterparty_check: DocumentCheckResult | None
locator: str | None
class FileOut(BaseModel):
"""Карточка файла: поля `stored_file`, текущее задание и документы."""
id: uuid.UUID
legal_entity_id: uuid.UUID
original_name: str | None
file_type: FileType
status: FileStatus
version_no: int
size_bytes: int | None
content_hash: str | None
uploaded_at: datetime.datetime | None
uploaded_by: uuid.UUID | None
superseded_by_file_id: uuid.UUID | None
job: JobBrief | None
documents: list[DocumentBrief]

View File

@ -15,13 +15,10 @@
import asyncio
import datetime
import hashlib
import unicodedata
import uuid
from collections import defaultdict
from pathlib import PurePosixPath
from typing import Any
from fastapi import HTTPException, status
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -29,6 +26,7 @@ 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 DomainValidationError
from src.common.filenames import CONTENT_TYPES, check_size, extension, safe_filename
from src.core.config import Settings
from src.core.storage import ObjectStorage
from src.domain.directory_import import (
@ -65,43 +63,6 @@ from src.services.related_groups import CODE_PREFIXES
OBJECT_TYPE = "directory_import"
IMPORT_DESCRIPTION = "импорт ЕЦП" # `description` событий изменения ГК/ЮЛ, сделанных импортом
S3_PREFIX = "uploads/imports"
DEFAULT_FILENAME = "import"
CONTENT_TYPES = {
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"csv": "text/csv",
}
MEGABYTE = 1024 * 1024
MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uuid}/{имя}` остаётся в пределах 1024 байт
MAX_SUFFIX_LENGTH = 16
def check_size(size: int, limit: int) -> None:
"""413, если файл больше `IMPORT_MAX_BYTES`."""
if size > limit:
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ")
_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"})
def safe_filename(filename: str | None) -> str:
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.
Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E,
нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена
`MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта.
"""
cleaned = "".join(
char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES
)
name = PurePosixPath(cleaned).name.strip()
if name in {"", ".", ".."}:
return DEFAULT_FILENAME
if len(name) > MAX_FILENAME_LENGTH:
suffix = PurePosixPath(name).suffix
suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else ""
name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix
return name
class DirectoryImportService:
@ -267,7 +228,7 @@ class DirectoryImportService:
bucket_role=S3BucketRole.uploads,
bucket=self._storage.bucket,
key=key,
content_type=CONTENT_TYPES.get(PurePosixPath(name).suffix.lstrip(".").lower()),
content_type=CONTENT_TYPES.get(extension(name)),
size_bytes=len(content),
sha256=hashlib.sha256(content).hexdigest(),
created_by=f"user:{self._audit.user_id}",

View File

@ -0,0 +1,248 @@
"""Первичные файлы организации: загрузка (spec primary-files «Загрузка файлов организации», «Дедуп по содержимому»;
audit-trail «События файлов и документов»; design D-4).
Загрузка одного файла — SAVEPOINT (`begin_nested()`) внутри транзакции запроса (`SessionDep`): отказ файла откатывает
только его записи, принятые раньше файлы того же запроса остаются. Порядок внутри SAVEPOINT:
1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3;
2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется);
3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся);
4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`,
`stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`.
Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи
`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован;
уборка таких объектов — ретеншн бакета (OQ-22).
"""
import hashlib
import uuid
from dataclasses import dataclass
from loguru import logger
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette import status as http_status
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainError, NotFoundError
from src.common.filenames import safe_filename
from src.contract import CONTRACT_VERSION, request_subject
from src.core.config import Settings
from src.core.storage import ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import VisibilityScope
from src.models.directories import LegalEntity
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.repositories import directories as directories_repo
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
OBJECT_TYPE = "stored_file"
UPLOADS_PREFIX = "uploads"
DUPLICATE_FILE = "DUPLICATE_FILE"
FILE_TOO_LARGE = "FILE_TOO_LARGE"
STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE"
STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно"
class StorageUnavailableError(DomainError):
"""503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект."""
def __init__(self, message: str = STORAGE_UNAVAILABLE_MESSAGE) -> None:
super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message)
@dataclass(frozen=True)
class AcceptedFile:
"""Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя."""
file_id: uuid.UUID
job_id: uuid.UUID
name: str
@dataclass(frozen=True)
class RejectedFile:
"""Отклонённый файл; `file_id` — существующий файл при `DUPLICATE_FILE`."""
name: str
code: str
message: str
file_id: uuid.UUID | None = None
@classmethod
def from_error(cls, name: str, error: DomainError) -> "RejectedFile":
"""Отказ по доменной ошибке загрузки файла `name`."""
raw_file_id = error.extra.get("file_id")
return cls(
name=name,
code=error.code,
message=error.message,
file_id=uuid.UUID(str(raw_file_id)) if raw_file_id is not None else None,
)
def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str:
"""Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей)."""
return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}"
class PrimaryFilesService:
"""Загрузка первичных файлов ЮЛ от имени пользователя запроса (право `DOCUMENT_EDIT` — на маршруте)."""
def __init__(
self,
session: AsyncSession,
audit: RequestAudit,
scope: VisibilityScope,
storage: ObjectStorage | None,
settings: Settings,
) -> None:
self._session = session
self._audit = audit
self._scope = scope
self._storage = storage
self._settings = settings
@property
def max_upload_bytes(self) -> int:
"""Предел размера одного файла (`UPLOAD_MAX_BYTES`)."""
return self._settings.UPLOAD_MAX_BYTES
async def get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
"""ЮЛ в области видимости пользователя; чужое и несуществующее — одинаковый 404 (§10.1)."""
entity = await directories_repo.get_legal_entity(self._session, self._scope, entity_id)
if entity is None:
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
return entity
def require_storage(self) -> ObjectStorage:
"""S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`."""
if self._storage is None:
raise StorageUnavailableError()
return self._storage
async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому»."""
stmt = (
select(StoredFile)
.where(
StoredFile.legal_entity_id == entity_id,
StoredFile.content_hash == sha256,
StoredFile.status != FileStatus.SUPERSEDED,
)
.order_by(StoredFile.uploaded_at, StoredFile.id)
.limit(1)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile:
"""Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT."""
storage = self.require_storage()
safe_name = safe_filename(name, default="")
detected = detect_file(safe_name, content)
sha256 = hashlib.sha256(content).hexdigest()
async with self._session.begin_nested():
await self._lock_content(entity.id, sha256)
duplicate = await self.find_duplicate(entity.id, sha256)
if duplicate is not None:
raise ConflictError(
DUPLICATE_FILE,
f"файл «{safe_name}» уже загружен для этого ЮЛ",
file_id=str(duplicate.id),
)
return await self._store(
entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected
)
async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None:
"""Сериализует загрузки одного содержимого в одно ЮЛ до конца транзакции запроса (двойной клик): второй
запрос дождётся commit первого и увидит дубль. Блокировка снимается с транзакцией, а не с SAVEPOINT."""
lock_key = func.hashtextextended(f"{OBJECT_TYPE}:{entity_id}:{sha256}", 0)
await self._session.execute(select(func.pg_advisory_xact_lock(lock_key)))
async def _store( # noqa: PLR0913 - шаги одной загрузки: всё, что уже вычислено в upload_one
self,
entity: LegalEntity,
*,
name: str,
content: bytes,
sha256: str,
storage: ObjectStorage,
detected: DetectedFile,
) -> AcceptedFile:
now = utc_now()
user_id = self._audit.user_id
file_id, job_id = uuid.uuid4(), uuid.uuid4()
key = upload_key(file_id, job_id, name)
s3_object = S3Object(
id=uuid.uuid4(),
bucket_role=S3BucketRole.uploads,
bucket=storage.bucket,
key=key,
content_type=detected.content_type,
size_bytes=len(content),
sha256=sha256,
created_by=f"user:{user_id}",
created_at=now,
)
self._session.add(s3_object)
await self._session.flush()
try:
await storage.put_object(key, content)
except Exception as error:
logger.warning(
"S3 не приняло загружаемый файл, файл отклонён. error_category=storage_error, key={key}, error={error}",
key=key,
error=f"{type(error).__name__}: {error}",
)
raise StorageUnavailableError(f"{STORAGE_UNAVAILABLE_MESSAGE}: файл «{name}» не сохранён") from error
stored_file = StoredFile(
id=file_id,
legal_entity_id=entity.id,
file_type=detected.file_type,
original_name=name,
s3_object_id=s3_object.id,
content_hash=sha256,
size_bytes=len(content),
status=FileStatus.UPLOADED,
version_no=1,
uploaded_by=user_id,
uploaded_at=now,
)
job = RecognitionJob(
id=job_id,
file_id=file_id,
pipeline=detected.pipeline,
request_subject=request_subject(detected.pipeline.value),
schema_version=CONTRACT_VERSION,
status=RecognitionStatus.queued,
attempt=1,
max_attempts=MAX_ATTEMPTS[detected.pipeline],
requested_at=now,
)
# Связей `relationship()` в моделях нет — порядок INSERT задаём сами: файл, затем задание (FK `file_id`);
# FK `current_recognition_job_id` не отложенный (use_alter): ссылку ставим, когда задание уже вставлено
self._session.add(stored_file)
await self._session.flush()
self._session.add(job)
await self._session.flush()
stored_file.current_recognition_job_id = job.id
await self._session.flush()
await self._audit.record(
action=AuditAction.FILE_UPLOAD,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=file_id,
after_value={
"original_name": name,
"content_hash": sha256,
"job_id": str(job_id),
"file_type": detected.file_type.value,
},
)
return AcceptedFile(file_id=file_id, job_id=job_id, name=name)

View File

@ -18,6 +18,7 @@ from fastapi import FastAPI
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.common.filenames import MAX_FILENAME_LENGTH, safe_filename
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
@ -32,7 +33,6 @@ from src.models.directories import (
)
from src.models.enums import AuditAction, AuditSection, EntityType, LeHistoryEvent, RelatedGroupKind, S3BucketRole
from src.models.files import S3Object
from src.services.directory_import import MAX_FILENAME_LENGTH, safe_filename
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import make_company_group, make_legal_entity

View File

@ -0,0 +1,533 @@
"""Загрузка файлов организации (spec primary-files «Загрузка», «Дедуп»; audit-trail «События файлов и документов»).
Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT): запрос — `begin_nested()`, каждый файл — ещё один
SAVEPOINT внутри него. Реальная ОСВ и параллельный дубль идут на `committed_*` (временная БД с зафиксированным seed,
сессия запроса как в проде): публикатор outbox и второй запрос работают в своих транзакциях и видят только
зафиксированное.
"""
import asyncio
import hashlib
import json
import uuid
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.api.dependencies.directories import get_app_settings
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
AuditSection,
FileStatus,
FileType,
RecognitionPipeline,
RecognitionStatus,
S3BucketRole,
)
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.services.recognition_exchange import publish_pending
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import make_company_group, make_legal_entity
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
HTTP_CREATED = 201
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_UNPROCESSABLE = 422
HTTP_UNAVAILABLE = 503
KA_RF = "ka_rf@armka.local" # Ростовский филиал
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал, право DOCUMENT_EDIT есть
CURATOR_GO = "curator_go@armka.local" # права DOCUMENT_EDIT нет
ROSTOV = "Ростовский региональный филиал"
REAL_OSV = "ОСВ_66 за 2024.xls"
REAL_OSV_PATH = ("osv", "rb", "2024-FY", REAL_OSV)
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
ZIP = b"PK\x03\x04"
PDF = b"%PDF-1.7\n"
XLS_TYPE = "application/vnd.ms-excel"
XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
TINY_LIMIT = 10 # байт: «файл 51 МиБ» без 51 МиБ в памяти теста
Upload = tuple[str, bytes]
def _files_url(entity_id: uuid.UUID) -> str:
return f"/legal-entities/{entity_id}/files"
def _xls(tag: str) -> bytes:
"""Байты с сигнатурой BIFF8 и уникальным хвостом (разный sha256 — не дубль)."""
return BIFF8 + tag.encode()
async def _post(client: httpx.AsyncClient, entity_id: uuid.UUID, *uploads: Upload) -> httpx.Response:
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
return await client.post(_files_url(entity_id), files=files)
async def _rostov_entity(session: AsyncSession, name: str = "ООО Загрузка") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
return await make_legal_entity(session, name=name, group=group)
async def _user_id(session: AsyncSession, email: str) -> uuid.UUID:
return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one()
async def _counts(session: AsyncSession) -> dict[str, int]:
return {
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (S3Object, StoredFile, RecognitionJob)
} | {
"file_upload": (
await session.execute(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD)
)
).scalar_one()
}
async def _upload_events(session: AsyncSession) -> Sequence[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD).order_by(AuditEvent.occurred_at)
return (await session.execute(stmt)).scalars().all()
@pytest.fixture
def storage(app: FastAPI) -> FakeStorage:
fake = FakeStorage()
app.state.storage = fake
return fake
class FlakyStorage(FakeStorage):
"""S3, у которого падает запись объектов с заданным суффиксом ключа."""
def __init__(self, failing_suffix: str) -> None:
super().__init__()
self.failing_suffix = failing_suffix
async def put_object(self, obj_name: str, file_data: bytes) -> None:
if obj_name.endswith(self.failing_suffix):
raise ConnectionError("S3 недоступно")
await super().put_object(obj_name, file_data)
class CapturedPublish:
"""Делегат `FakeNats`: запоминает тела опубликованных запросов."""
def __init__(self) -> None:
self.payloads: list[dict[str, Any]] = []
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: # noqa: ARG002 - протокол издателя
self.payloads.append(json.loads(payload))
# --- «Реальная ОСВ принята и ушла в outbox» ----------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.fixtures
async def test_real_osv_is_accepted_and_published_from_outbox(
fixtures_dir: Any,
committed_client: AuthClientFactory,
committed_sessionmaker: async_sessionmaker[AsyncSession],
committed_storage: FakeStorage,
) -> None:
content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes()
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session, "АО Русский Бисквит")
user_id = await _user_id(session, KA_RF)
client = await committed_client(KA_RF)
response = await _post(client, entity.id, (REAL_OSV, content))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert body["rejected"] == []
(accepted,) = body["accepted"]
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
assert accepted["name"] == REAL_OSV
key = f"uploads/{file_id}/{job_id}/{REAL_OSV}"
assert committed_storage.objects == {key: content}
async with committed_sessionmaker() as session:
file = await session.get(StoredFile, file_id)
job = await session.get(RecognitionJob, job_id)
assert file is not None and job is not None
s3 = await session.get(S3Object, file.s3_object_id)
assert s3 is not None
sha256 = hashlib.sha256(content).hexdigest()
assert (file.status, file.file_type, file.content_hash, file.version_no) == (
FileStatus.UPLOADED,
FileType.XLS,
sha256,
1,
)
assert (file.legal_entity_id, file.uploaded_by, file.original_name) == (entity.id, user_id, REAL_OSV)
assert file.current_recognition_job_id == job.id
assert (job.status, job.pipeline, job.max_attempts, job.file_id) == (
RecognitionStatus.queued,
RecognitionPipeline.tabular,
3,
file_id,
)
assert (s3.bucket_role, s3.key, s3.sha256, s3.size_bytes) == (S3BucketRole.uploads, key, sha256, len(content))
capture = CapturedPublish()
report = await publish_pending(
committed_sessionmaker,
FakeNats(delegate=capture),
ack_timeout=timedelta(seconds=30),
max_republish=3,
)
assert report.published == 1
(request,) = capture.payloads
REQUEST_VALIDATOR.validate(request)
assert request["s3"] == {"bucket": committed_storage.bucket, "key": key}
assert (request["job_id"], request["file_id"], request["file_type"]) == (str(job_id), str(file_id), "XLS")
assert request["original_name"] == REAL_OSV
# --- поля записей, имя файла, аудит ------------------------------------------------------------------------------
@pytest.mark.integration
async def test_hostile_name_gives_flat_key_and_full_records(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""Review Focus 4: кириллица, пробелы, `..` и обратные слэши — ключ S3 без путей, имя без каталогов."""
entity = await _rostov_entity(seeded)
user_id = await _user_id(seeded, KA_RF)
client = await auth_client(KA_RF)
content = _xls("hostile")
response = await _post(client, entity.id, ("../..\\ОСВ 60 (2).xls", content))
assert response.status_code == HTTP_CREATED, response.text
(accepted,) = response.json()["accepted"]
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
assert accepted["name"] == "ОСВ 60 (2).xls"
key = f"uploads/{file_id}/{job_id}/ОСВ 60 (2).xls"
assert list(storage.objects) == [key]
assert ".." not in key
assert "\\" not in key
assert key.count("/") == 3 # noqa: PLR2004 - uploads/{file_id}/{job_id}/{имя}
file = await seeded.get(StoredFile, file_id)
job = await seeded.get(RecognitionJob, job_id)
assert file is not None and job is not None
s3 = await seeded.get(S3Object, file.s3_object_id)
assert s3 is not None
sha256 = hashlib.sha256(content).hexdigest()
assert file.original_name == "ОСВ 60 (2).xls"
assert (file.status, file.version_no, file.content_hash, file.size_bytes) == (
FileStatus.UPLOADED,
1,
sha256,
len(content),
)
assert file.uploaded_by == user_id
assert file.uploaded_at is not None
assert file.current_recognition_job_id == job_id
assert job.request_subject == "recognition.tabular.request"
assert job.schema_version == "1.0"
assert (job.status, job.attempt, job.max_attempts) == (RecognitionStatus.queued, 1, 3)
assert job.requested_at is not None
assert (s3.bucket_role, s3.bucket, s3.key, s3.content_type) == (
S3BucketRole.uploads,
storage.bucket,
key,
XLS_TYPE,
)
assert (s3.sha256, s3.size_bytes, s3.created_by) == (sha256, len(content), f"user:{user_id}")
@pytest.mark.integration
async def test_image_file_goes_to_image_pipeline_with_one_attempt(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
response = await _post(client, entity.id, ("ББ 2024.pdf", PDF + b"scan"))
assert response.status_code == HTTP_CREATED, response.text
job = await seeded.get(RecognitionJob, uuid.UUID(response.json()["accepted"][0]["job_id"]))
assert job is not None
assert (job.pipeline, job.max_attempts, job.request_subject) == (
RecognitionPipeline.image,
1,
"recognition.image.request",
)
assert len(storage.objects) == 1
@pytest.mark.integration
async def test_two_files_write_two_upload_events(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""audit-trail «Загрузка двух файлов»: два `FILE_UPLOAD` с `object_id` каждого файла."""
entity = await _rostov_entity(seeded)
user_id = await _user_id(seeded, KA_RF)
client = await auth_client(KA_RF)
first, second = _xls("first"), ZIP + b"second"
response = await _post(client, entity.id, ("ОСВ 60.xls", first), ("ОСВ 62.xlsx", second))
assert response.status_code == HTTP_CREATED, response.text
accepted = response.json()["accepted"]
assert [item["name"] for item in accepted] == ["ОСВ 60.xls", "ОСВ 62.xlsx"]
events = await _upload_events(seeded)
assert len(events) == len(accepted)
by_object = {event.object_id: event for event in events}
for item, content, file_type in ((accepted[0], first, "XLS"), (accepted[1], second, "XLSX")):
event = by_object[uuid.UUID(item["file_id"])]
assert (event.section, event.object_type, event.user_id) == (
AuditSection.PRIMARY_DOCUMENTS,
"stored_file",
user_id,
)
assert event.after_value == {
"original_name": item["name"],
"content_hash": hashlib.sha256(content).hexdigest(),
"job_id": item["job_id"],
"file_type": file_type,
}
assert len(storage.objects) == len(accepted)
# --- «Смесь принятых и отклонённых», «Расширение не совпадает с содержимым» ---------------------------------------
@pytest.mark.integration
async def test_mixed_request_accepts_valid_and_rejects_others(
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
app.dependency_overrides[get_app_settings] = lambda: get_settings().model_copy(
update={"UPLOAD_MAX_BYTES": TINY_LIMIT}
)
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
xlsx = ZIP + b"ok" # 6 байт — в пределах лимита
big = BIFF8 + b"overflow" # больше лимита — как файл 51 МиБ при 50 МиБ
response = await _post(client, entity.id, ("osv.xlsx", xlsx), ("report.docx", ZIP + b"d"), ("big.xls", big))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert [item["name"] for item in body["accepted"]] == ["osv.xlsx"]
assert {item["name"]: item["code"] for item in body["rejected"]} == {
"report.docx": "UNSUPPORTED_FILE",
"big.xls": "FILE_TOO_LARGE",
}
assert all(item["message"] and item["file_id"] is None for item in body["rejected"])
after = await _counts(seeded)
assert {name: after[name] - before[name] for name in after} == {
"s3_object": 1,
"stored_file": 1,
"recognition_job": 1,
"file_upload": 1,
}
(key,) = storage.objects
assert key.endswith("/osv.xlsx")
s3 = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one()
assert s3.content_type == XLSX_TYPE
@pytest.mark.integration
async def test_signature_mismatch_is_rejected_with_422(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("osv.xlsx", _xls("biff")))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["accepted"] == []
(rejected,) = response.json()["rejected"]
assert (rejected["name"], rejected["code"]) == ("osv.xlsx", "UNSUPPORTED_FILE")
assert await _counts(seeded) == before
assert storage.objects == {}
@pytest.mark.integration
async def test_empty_filename_is_unsupported(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""Review Focus 4: имя `..` (после очистки — пустое) — `UNSUPPORTED_FILE`, а не файл без имени."""
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
response = await _post(client, entity.id, ("..", _xls("noname")))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
(rejected,) = response.json()["rejected"]
assert (rejected["name"], rejected["code"]) == ("", "UNSUPPORTED_FILE")
assert storage.objects == {}
# --- «Чужое ЮЛ», права, S3 --------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_foreign_entity_is_404_like_missing(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(CLIENT_MANAGER)
before = await _counts(seeded)
foreign = await _post(client, entity.id, ("osv.xls", _xls("foreign")))
missing = await _post(client, uuid.uuid4(), ("osv.xls", _xls("missing")))
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND, foreign.text
assert foreign.json() == missing.json()
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
assert await _counts(seeded) == before
assert storage.objects == {}
@pytest.mark.integration
async def test_upload_requires_document_edit(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(CURATOR_GO)
response = await _post(client, entity.id, ("osv.xls", _xls("curator")))
assert response.status_code == HTTP_FORBIDDEN, response.text
assert "DOCUMENT_EDIT" in response.json()["detail"]
assert storage.objects == {}
@pytest.mark.integration
async def test_without_storage_is_503(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await _rostov_entity(seeded) # `app.state.storage` фикстуры `app` — None
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("osv.xls", _xls("nostorage")))
assert response.status_code == HTTP_UNAVAILABLE, response.text
assert response.json()["detail"]["code"] == "STORAGE_UNAVAILABLE"
assert await _counts(seeded) == before
@pytest.mark.integration
async def test_storage_failure_rejects_only_that_file(
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Отказ S3 на одном файле откатывает только его SAVEPOINT: принятый файл и его записи остаются."""
storage = FlakyStorage(failing_suffix="/broken.xls")
app.state.storage = storage
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("good.xls", _xls("good")), ("broken.xls", _xls("broken")))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert [item["name"] for item in body["accepted"]] == ["good.xls"]
(rejected,) = body["rejected"]
assert (rejected["name"], rejected["code"]) == ("broken.xls", "STORAGE_UNAVAILABLE")
after = await _counts(seeded)
assert {name: after[name] - before[name] for name in after} == {
"s3_object": 1,
"stored_file": 1,
"recognition_job": 1,
"file_upload": 1,
}
assert [key.rsplit("/", 1)[1] for key in storage.objects] == ["good.xls"]
# --- «Дедуп по содержимому» --------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_duplicate_in_same_entity_is_rejected_other_entity_accepts(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
other = await _rostov_entity(seeded, "ООО Другое")
client = await auth_client(KA_RF)
content = _xls("dup")
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
assert first.status_code == HTTP_CREATED, first.text
first_id = first.json()["accepted"][0]["file_id"]
before = await _counts(seeded)
again = await _post(client, entity.id, ("ОСВ 60 копия.xls", content))
assert again.status_code == HTTP_UNPROCESSABLE, again.text
(rejected,) = again.json()["rejected"]
assert (rejected["code"], rejected["file_id"], rejected["name"]) == ("DUPLICATE_FILE", first_id, "ОСВ 60 копия.xls")
assert await _counts(seeded) == before
assert len(storage.objects) == 1
elsewhere = await _post(client, other.id, ("ОСВ 60.xls", content))
assert elsewhere.status_code == HTTP_CREATED, elsewhere.text
@pytest.mark.integration
async def test_superseded_file_does_not_block_same_content(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
content = _xls("superseded")
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
first_id = uuid.UUID(first.json()["accepted"][0]["file_id"])
await seeded.execute(update(StoredFile).where(StoredFile.id == first_id).values(status=FileStatus.SUPERSEDED))
again = await _post(client, entity.id, ("ОСВ 60.xls", content))
assert again.status_code == HTTP_CREATED, again.text
assert again.json()["accepted"][0]["file_id"] != str(first_id)
assert len(storage.objects) == 2 # noqa: PLR2004 - старый и новый объект
@pytest.mark.integration
async def test_concurrent_duplicates_accept_only_one(
committed_client: AuthClientFactory,
committed_sessionmaker: async_sessionmaker[AsyncSession],
committed_storage: FakeStorage,
) -> None:
"""Двойной клик: два запроса с одним содержимым одновременно — принят один, второй `DUPLICATE_FILE`."""
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session)
first_client, second_client = await committed_client(KA_RF), await committed_client(KA_RF)
content = _xls("race")
responses = await asyncio.gather(
_post(first_client, entity.id, ("ОСВ 60.xls", content)),
_post(second_client, entity.id, ("ОСВ 60.xls", content)),
)
assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_UNPROCESSABLE]
accepted = next(r for r in responses if r.status_code == HTTP_CREATED).json()["accepted"][0]
rejected = next(r for r in responses if r.status_code == HTTP_UNPROCESSABLE).json()["rejected"][0]
assert (rejected["code"], rejected["file_id"]) == ("DUPLICATE_FILE", accepted["file_id"])
async with committed_sessionmaker() as session:
files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one()
assert files == 1
assert len(committed_storage.objects) == 1

View File

@ -0,0 +1,75 @@
"""Тип загружаемого файла: расширение → `FileType`/pipeline, сверка с сигнатурой (spec primary-files «Загрузка»)."""
import pytest
from src.common.exceptions import DomainValidationError
from src.domain.file_types import MAX_ATTEMPTS, detect_file
from src.models.enums import FileType, RecognitionPipeline
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 16
ZIP = b"PK\x03\x04" + b"\x00" * 16
PDF = b"%PDF-1.7\n" + b"\x00" * 16
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
JPG = b"\xff\xd8\xff\xe0" + b"\x00" * 16
@pytest.mark.parametrize(
("name", "content", "file_type", "pipeline", "content_type"),
[
("ОСВ_66 за 2024.xls", BIFF8, FileType.XLS, RecognitionPipeline.tabular, "application/vnd.ms-excel"),
(
"osv.xlsx",
ZIP,
FileType.XLSX,
RecognitionPipeline.tabular,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
(
"osv.xlsb",
ZIP,
FileType.XLSB,
RecognitionPipeline.tabular,
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
),
("ББ 2024.pdf", PDF, FileType.PDF, RecognitionPipeline.image, "application/pdf"),
("скан.png", PNG, FileType.PNG, RecognitionPipeline.image, "image/png"),
("скан.jpg", JPG, FileType.JPG, RecognitionPipeline.image, "image/jpeg"),
],
)
def test_type_by_extension_and_signature(
name: str, content: bytes, file_type: FileType, pipeline: RecognitionPipeline, content_type: str
) -> None:
detected = detect_file(name, content)
assert detected.file_type is file_type
assert detected.pipeline is pipeline
assert detected.content_type == content_type
def test_extension_is_case_insensitive_and_jpeg_is_jpg() -> None:
assert detect_file("ОСВ.XLS", BIFF8).file_type is FileType.XLS
assert detect_file("скан.JPEG", JPG).file_type is FileType.JPG
def test_max_attempts_by_pipeline() -> None:
assert MAX_ATTEMPTS == {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1}
@pytest.mark.parametrize(
("name", "content"),
[
("osv.xlsx", BIFF8), # сценарий спеки «Расширение не совпадает с содержимым»
("osv.xls", ZIP),
("scan.pdf", PNG),
("scan.png", JPG),
("osv.xls", b""),
("report.docx", ZIP), # zip, но не таблица ОСВ
("notes.txt", b"text"),
("osv", BIFF8), # без расширения
("", BIFF8), # Review Focus 4: пустое имя
],
)
def test_unsupported_or_mismatched_file_is_rejected(name: str, content: bytes) -> None:
with pytest.raises(DomainValidationError) as error:
detect_file(name, content)
assert error.value.code == "UNSUPPORTED_FILE"
assert error.value.message

View File

@ -44,6 +44,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
f"{A}/roles": {"get"},
f"{A}/permissions": {"get"},
f"{A}/roles/{{code}}/permissions": {"put"},
# primary-files (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"post"},
}
# Модели, которые обязаны быть в схеме ровно один раз под коротким именем

View File

@ -116,9 +116,9 @@ class PrimaryFilesService:
```
- `schemas/files.py`: `UploadResult{accepted: list[AcceptedOut], rejected: list[RejectedOut]}`, `JobBrief{id, status, error_code, error_message, warnings: list[str], mapping_warnings: list[str], finished_at}`, `DocumentBrief{id, document_type, osv_account_code, period_year, period_quarter, is_cumulative, status, period_check, counterparty_check, locator}`, `FileOut{id, legal_entity_id, original_name, file_type, status, version_no, size_bytes, content_hash, uploaded_at, uploaded_by, superseded_by_file_id, job: JobBrief | None, documents: list[DocumentBrief]}` (используется Task 9).
- [ ] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите.
- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`.
- [ ] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`.
- [x] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите.
- [x] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`.
- [x] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`.
---

View File

@ -18,7 +18,7 @@
## 2. Загрузка и статусы
- [ ] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py`
- [x] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py`
(`upload_many`, дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`),
`schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт
`POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль);