diff --git a/api/src/domain/pdf_text_layer.py b/api/src/domain/pdf_text_layer.py index 8762c9e..dec6224 100644 --- a/api/src/domain/pdf_text_layer.py +++ b/api/src/domain/pdf_text_layer.py @@ -13,12 +13,16 @@ import io from pypdf import PdfReader +# R-15: текстовый слой скана/цифрового PDF виден уже на первых страницах; разбор всех страниц большого файла под +# advisory-lock загрузки не нужен +MAX_PAGES_CHECKED = 10 + def has_text_layer(content: bytes) -> bool: - """Есть ли хотя бы на одной странице `content` извлекаемый текст; битый/зашифрованный PDF и любая другая - ошибка разбора — `False`, без исключения.""" + """Есть ли хотя бы на одной из первых `MAX_PAGES_CHECKED` страниц `content` извлекаемый текст; битый/зашифрованный + PDF и любая другая ошибка разбора — `False`, без исключения.""" try: reader = PdfReader(io.BytesIO(content)) - return any(page.extract_text().strip() for page in reader.pages) + return any(page.extract_text().strip() for page in reader.pages[:MAX_PAGES_CHECKED]) except Exception: return False diff --git a/api/src/models/files.py b/api/src/models/files.py index 328df9e..217693c 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -70,9 +70,6 @@ class StoredFile(UUIDPrimaryMixin, Base): status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False) version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id")) - # v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой - # (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6) - is_scan: Mapped[bool | None] = mapped_column(Boolean) current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey( @@ -84,9 +81,13 @@ class StoredFile(UUIDPrimaryMixin, Base): uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) - # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя - # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок + # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). + # Колонка добавлена миграцией 0006 (ALTER TABLE — в конец); check_dbml сверяет порядок consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) + # v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой + # (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6). Последняя колонка: + # миграция 0012 добавляет её ALTER TABLE (в конец), а check_dbml сверяет порядок + is_scan: Mapped[bool | None] = mapped_column(Boolean) class Document(UUIDPrimaryMixin, Base): @@ -172,9 +173,6 @@ class RecognitionJob(UUIDPrimaryMixin, Base): error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(Text) result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) - # v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет - # решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9) - raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) result_schema_version: Mapped[str | None] = mapped_column(String) group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb()) metrics: Mapped[Any | None] = mapped_column(nullable_jsonb()) @@ -183,3 +181,7 @@ class RecognitionJob(UUIDPrimaryMixin, Base): fields_unrecognized: Mapped[int | None] = mapped_column(Integer) # v5.19: service.statements_version результата — коммит копии общего кода arm_statements statements_version: Mapped[str | None] = mapped_column(String) + # v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет + # решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9). + # Последняя колонка: миграция 0012 добавляет её ALTER TABLE (в конец), а check_dbml сверяет порядок + raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) diff --git a/api/src/repositories/quality_facts.py b/api/src/repositories/quality_facts.py index 8c84c32..f6eb24e 100644 --- a/api/src/repositories/quality_facts.py +++ b/api/src/repositories/quality_facts.py @@ -5,6 +5,9 @@ DBML/ORM; колонки, не переданные вызывающим, берут значения по умолчанию БД (`server_default`). """ +import uuid + +from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact @@ -23,3 +26,9 @@ async def insert_outcome_fact(session: AsyncSession, **columns) -> None: async def insert_failure_fact(session: AsyncSession, **columns) -> None: """Добавляет факт технического отказа задания или ошибки маппинга (design §4.3).""" session.add(QualityFailureFact(**columns)) + + +async def has_intake_fact_for_file(session: AsyncSession, file_id: uuid.UUID) -> bool: + """Есть ли у файла факт приёма: тогда объект `uploads` файла нужен эталонному прогону (`upload_s3_key`) и не + экспирится при удалении файла (spec quality-metrics-model, OQ-179).""" + return bool(await session.scalar(select(exists().where(QualityIntakeFact.file_id == file_id)))) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index effb898..b69c8bc 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -16,6 +16,7 @@ audit-trail «События файлов и документов»; design D-4) уборка таких объектов — ретеншн бакета (OQ-22). """ +import asyncio import datetime import hashlib import uuid @@ -59,7 +60,7 @@ from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.repositories import statements as statements_repo -from src.repositories.quality_facts import insert_outcome_fact +from src.repositories.quality_facts import has_intake_fact_for_file, insert_outcome_fact from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents @@ -418,12 +419,18 @@ class PrimaryFilesService: self._session, [document.id for document in documents] ) before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") - object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + # R-14: объект uploads, на который ссылается факт приёма (`upload_s3_key`), нужен эталонному прогону — не + # экспирится (spec quality-metrics-model); объект raw сюда не попадает и так + keep_upload = await has_intake_fact_for_file(self._session, file_id) + object_ids = [ + *([] if keep_upload else [file.s3_object_id]), + *(job.result_s3_object_id for job in jobs if job.result_s3_object_id), + ] await self._write_delete_outcome_facts(documents, issue_counts, utc_now()) await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла owned_prefixes = [ - (S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"), + *([] if keep_upload else [(S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/")]), (S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"), ] await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now()) @@ -532,7 +539,7 @@ class PrimaryFilesService: # §14.6: признак скана — только для PDF, по отсутствию текстового слоя (true = скан, false = цифровой, в # т.ч. со скрытым OCR-слоем); для остальных типов файла — NULL (pipeline = tabular) - is_scan = not has_text_layer(content) if detected.file_type is FileType.PDF else None + is_scan = not await asyncio.to_thread(has_text_layer, content) if detected.file_type is FileType.PDF else None stored_file = StoredFile( id=file_id, legal_entity_id=entity.id, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 3190e1b..500007b 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -31,7 +31,9 @@ from src.models.enums import ( FileStatus, PeriodQuarter, QualityOutcome, + RecognitionPipeline, RecognitionStatus, + S3BucketRole, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -43,6 +45,7 @@ from src.models.statements import ( FinancialStatementMeta, FinancialStatementValue, ) +from src.repositories.quality_facts import insert_intake_fact from src.services.result_mapping import process_once from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( @@ -219,6 +222,62 @@ async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client assert foreign is not None and foreign.expires_at is None +async def test_delete_keeps_uploads_and_raw_objects_referenced_by_intake_fact( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Spec quality-metrics-model «Удаление файла с зарегистрированным сырым ответом»: `expires_at` остаётся NULL у + объекта raw и у объекта uploads, на который ссылается факт приёма; у результата — проставляется (I-4).""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + result_object = await make_result_object(seeded, job=job, storage=FakeStorage(), result={}) + raw = S3Object( + bucket_role=S3BucketRole.raw, + bucket="raw", + key=f"raw/{job.id}.json", + created_at=utc_now(), + ) + seeded.add(raw) + await seeded.flush() + job.raw_s3_object_id = raw.id + upload = await seeded.get(S3Object, file.s3_object_id) + assert upload is not None + await insert_intake_fact( + seeded, + document_id=uuid.uuid4(), + file_id=file.id, + job_id=job.id, + attempt=1, + legal_entity_id=entity.id, + legal_entity_name=entity.name, + document_type=DocumentType.BALANCE, + pipeline=RecognitionPipeline.image, + is_scan=True, + intake_at=utc_now(), + intake_status=DocumentStatus.RECOGNIZED, + upload_s3_key=upload.key, + raw_s3_key=raw.key, + payload={}, + ) + await seeded.flush() + file_id, upload_id, raw_id, result_id = file.id, upload.id, raw.id, result_object.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + expires = {} + for name, object_id in (("uploads", upload_id), ("raw", raw_id), ("results", result_id)): + s3 = await seeded.get(S3Object, object_id, populate_existing=True) + assert s3 is not None + expires[name] = s3.expires_at + assert expires["uploads"] is None + assert expires["raw"] is None + assert expires["results"] is not None + + async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" entity = await _rostov_entity(seeded) diff --git a/api/tests/test_domain_pdf_text_layer.py b/api/tests/test_domain_pdf_text_layer.py index 99c22d7..9688f16 100644 --- a/api/tests/test_domain_pdf_text_layer.py +++ b/api/tests/test_domain_pdf_text_layer.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from src.domain import pdf_text_layer from src.domain.pdf_text_layer import has_text_layer SCANNED_PDF = Path("ocr") / "aep_9.pdf" # реальный скан без текстового слоя (2 страницы, extract_text == "") @@ -50,3 +51,9 @@ def test_has_text_layer_false_for_scanned_pdf(fixtures_dir: Path) -> None: def test_has_text_layer_false_for_corrupt_pdf_without_raising() -> None: assert has_text_layer(b"not a pdf") is False + + +def test_has_text_layer_looks_only_at_first_pages(monkeypatch: pytest.MonkeyPatch) -> None: + """R-15: страницы за `MAX_PAGES_CHECKED` не разбираются (при лимите 0 текст не виден).""" + monkeypatch.setattr(pdf_text_layer, "MAX_PAGES_CHECKED", 0) + assert has_text_layer(_digital_pdf_bytes()) is False diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 8cdd8b4..5550581 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -609,7 +609,8 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N conn, version_no=1, valid_from=datetime.date(2020, 1, 1) ) # v5.23: версиям нужна версия правил - await alembic(scratch_db_url, "upgrade", "head") + await alembic(scratch_db_url, "upgrade", V5_18) + # на ревизии 0006, не на head: поздние миграции (0012 is_scan) добавляют свои колонки в конец async with engine.begin() as conn: assert await _consolidation_id_column(conn) == [("YES", "uuid")] last = await _scalars( diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index b212fec..58a4643 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -249,3 +249,6 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge | | 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 | | 07.11.2026 | R-12 (final review M-2): `GET /quality-metrics/export` пишет `audit_event` (`action=EXPORT`, `section=EXPORT`, `object_type=quality_metrics`, `object_id=null`, `after_value={report, format, filters, rows}`) в транзакции запроса; `AuditAction.EXPORT` существует (как у экспорта сводной ОСВ, `consolidated_osv.py`); права — прежние `QUALITY_METRICS_VIEW` | выгрузка содержит названия ЮЛ и массовая; обоснование «нет в `audit_action`» в Task 15 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала | +| 07.11.2026 | R-13 (final review I-3, рулинг владельца): `stored_file.is_scan` и `recognition_job.raw_s3_object_id` — последние колонки таблиц, и в ORM, и в DBML v5.25 (docs); версия DBML не меняется. Тест миграции 0006 проверяет «последняя колонка» на ревизии `0006_dbml_v5_18`, а не на head | миграция 0012 добавляет колонки `ADD COLUMN` (в конец), `test_migrated_schema_equals_orm_create_all` и 0006-тест требуют порядок ORM = порядок миграции; прецедент v5.18 `consolidation_id` | порядок колонок в DBML v5.25 не совпадает с черновиками аналитики; смена порядка в ORM требует правки docs PR #203 (merge вместе) | +| 07.11.2026 | R-14 (final review I-4): при удалении файла, у которого есть `quality_intake_fact` с `file_id`, объект `uploads` файла не получает `expires_at` (исключён из `object_ids` и из префикса `uploads/{file_id}/`); определение — один запрос `has_intake_fact_for_file` по `file_id`, а не сравнение `upload_s3_key` | spec quality-metrics-model: объект нужен эталонному прогону (OQ-179); сравнение по `file_id` дешевле и покрывает все факты файла; объект `raw` в `object_ids` не входит и раньше | если у файла несколько документов и факт есть не у всех, объект сохраняется целиком (бакет растёт на размер файла); если `file_id` в факте ошибочен — объект экспирится и эталон потеряется | +| 07.11.2026 | R-15 (final review M-3): `has_text_layer` вызывается через `asyncio.to_thread` и разбирает не больше `MAX_PAGES_CHECKED = 10` первых страниц | pypdf синхронен и работал в event loop под advisory-lock загрузки; текстовый слой, если есть, виден на первых страницах формы отчётности (ББ/ОФР — 1-3 листа), 10 — запас | PDF, у которого текст только после 10-й страницы (титулы-сканы), будет помечен `is_scan = true` — метрика «скан» чуть завысит долю сканов |