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
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
# R-15: текстовый слой скана/цифрового PDF виден уже на первых страницах; разбор всех страниц большого файла под
|
||||||
|
# advisory-lock загрузки не нужен
|
||||||
|
MAX_PAGES_CHECKED = 10
|
||||||
|
|
||||||
|
|
||||||
def has_text_layer(content: bytes) -> bool:
|
def has_text_layer(content: bytes) -> bool:
|
||||||
"""Есть ли хотя бы на одной странице `content` извлекаемый текст; битый/зашифрованный PDF и любая другая
|
"""Есть ли хотя бы на одной из первых `MAX_PAGES_CHECKED` страниц `content` извлекаемый текст; битый/зашифрованный
|
||||||
ошибка разбора — `False`, без исключения."""
|
PDF и любая другая ошибка разбора — `False`, без исключения."""
|
||||||
try:
|
try:
|
||||||
reader = PdfReader(io.BytesIO(content))
|
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:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -70,9 +70,6 @@ class StoredFile(UUIDPrimaryMixin, Base):
|
|||||||
status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False)
|
status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False)
|
||||||
version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
|
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"))
|
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(
|
current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey(
|
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_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))
|
uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
updated_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). Последняя
|
# v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8).
|
||||||
# колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок
|
# Колонка добавлена миграцией 0006 (ALTER TABLE — в конец); check_dbml сверяет порядок
|
||||||
consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id"))
|
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):
|
class Document(UUIDPrimaryMixin, Base):
|
||||||
@ -172,9 +173,6 @@ class RecognitionJob(UUIDPrimaryMixin, Base):
|
|||||||
error_code: Mapped[str | None] = mapped_column(String)
|
error_code: Mapped[str | None] = mapped_column(String)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text)
|
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"))
|
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)
|
result_schema_version: Mapped[str | None] = mapped_column(String)
|
||||||
group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb())
|
group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb())
|
||||||
metrics: 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)
|
fields_unrecognized: Mapped[int | None] = mapped_column(Integer)
|
||||||
# v5.19: service.statements_version результата — коммит копии общего кода arm_statements
|
# v5.19: service.statements_version результата — коммит копии общего кода arm_statements
|
||||||
statements_version: Mapped[str | None] = mapped_column(String)
|
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`).
|
DBML/ORM; колонки, не переданные вызывающим, берут значения по умолчанию БД (`server_default`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact
|
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:
|
async def insert_failure_fact(session: AsyncSession, **columns) -> None:
|
||||||
"""Добавляет факт технического отказа задания или ошибки маппинга (design §4.3)."""
|
"""Добавляет факт технического отказа задания или ошибки маппинга (design §4.3)."""
|
||||||
session.add(QualityFailureFact(**columns))
|
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).
|
уборка таких объектов — ретеншн бакета (OQ-22).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
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 directories as directories_repo
|
||||||
from src.repositories import files as files_repo
|
from src.repositories import files as files_repo
|
||||||
from src.repositories import statements as statements_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.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
|
||||||
@ -418,12 +419,18 @@ class PrimaryFilesService:
|
|||||||
self._session, [document.id for document in documents]
|
self._session, [document.id for document in documents]
|
||||||
)
|
)
|
||||||
before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json")
|
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 self._write_delete_outcome_facts(documents, issue_counts, utc_now())
|
||||||
await files_repo.delete_file_rows(self._session, file)
|
await files_repo.delete_file_rows(self._session, file)
|
||||||
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
|
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
|
||||||
owned_prefixes = [
|
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}/"),
|
(S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"),
|
||||||
]
|
]
|
||||||
await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now())
|
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 = цифровой, в
|
# §14.6: признак скана — только для PDF, по отсутствию текстового слоя (true = скан, false = цифровой, в
|
||||||
# т.ч. со скрытым OCR-слоем); для остальных типов файла — NULL (pipeline = tabular)
|
# т.ч. со скрытым 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(
|
stored_file = StoredFile(
|
||||||
id=file_id,
|
id=file_id,
|
||||||
legal_entity_id=entity.id,
|
legal_entity_id=entity.id,
|
||||||
|
|||||||
@ -31,7 +31,9 @@ from src.models.enums import (
|
|||||||
FileStatus,
|
FileStatus,
|
||||||
PeriodQuarter,
|
PeriodQuarter,
|
||||||
QualityOutcome,
|
QualityOutcome,
|
||||||
|
RecognitionPipeline,
|
||||||
RecognitionStatus,
|
RecognitionStatus,
|
||||||
|
S3BucketRole,
|
||||||
)
|
)
|
||||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
@ -43,6 +45,7 @@ from src.models.statements import (
|
|||||||
FinancialStatementMeta,
|
FinancialStatementMeta,
|
||||||
FinancialStatementValue,
|
FinancialStatementValue,
|
||||||
)
|
)
|
||||||
|
from src.repositories.quality_facts import insert_intake_fact
|
||||||
from src.services.result_mapping import process_once
|
from src.services.result_mapping import process_once
|
||||||
from tests.conftest import AuthClientFactory, FakeStorage
|
from tests.conftest import AuthClientFactory, FakeStorage
|
||||||
from tests.factories import (
|
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
|
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:
|
async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||||
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
|
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
|
||||||
entity = await _rostov_entity(seeded)
|
entity = await _rostov_entity(seeded)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.domain import pdf_text_layer
|
||||||
from src.domain.pdf_text_layer import has_text_layer
|
from src.domain.pdf_text_layer import has_text_layer
|
||||||
|
|
||||||
SCANNED_PDF = Path("ocr") / "aep_9.pdf" # реальный скан без текстового слоя (2 страницы, extract_text == "")
|
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:
|
def test_has_text_layer_false_for_corrupt_pdf_without_raising() -> None:
|
||||||
assert has_text_layer(b"not a pdf") is False
|
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)
|
conn, version_no=1, valid_from=datetime.date(2020, 1, 1)
|
||||||
) # v5.23: версиям нужна версия правил
|
) # 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:
|
async with engine.begin() as conn:
|
||||||
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
|
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
|
||||||
last = await _scalars(
|
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 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge |
|
||||||
| 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 |
|
| 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-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