fix(#190): I-3 is_scan и raw_s3_object_id последними колонками, I-4 uploads с фактом приёма не экспирится, M-3 разбор PDF вне event loop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
85c5ab8596
commit
7a6c3641c1
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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` — метрика «скан» чуть завысит долю сканов |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user