feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR
Приём файла результата выбирает модель по MAJOR (parse_result); нечитаемый JSON и неизвестный MAJOR — неповторяемая ошибка схемы (T5.2). Статус ОСВ 2.0 — osv_status_v2, предупреждения листа (OSV_*) — в document_issue. Решения P3-46…P3-49. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5e45a6ca7e
commit
2b8fafc3b3
@ -35,6 +35,7 @@ __all__ = [
|
||||
"account_root",
|
||||
"account_sort_key",
|
||||
"build_entries",
|
||||
"checks_clean",
|
||||
"counterparty_check",
|
||||
"document_fields",
|
||||
"document_status",
|
||||
@ -227,7 +228,8 @@ def counterparty_check(
|
||||
return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH
|
||||
|
||||
|
||||
def _checks_clean(checks: OsvChecks | None) -> bool:
|
||||
def checks_clean(checks: OsvChecks | None) -> bool:
|
||||
"""Контрольные суммы листа (§1.4) без нарушений; `checks` нет — «не проверялось», нарушений нет."""
|
||||
if checks is None:
|
||||
return True
|
||||
return (
|
||||
@ -246,7 +248,7 @@ def document_status(
|
||||
) -> DocumentStatus:
|
||||
"""§9.3, D-7 (OQ-05): `RECOGNIZED` только при задании `done`, `checks` без нарушений, контрагенте шапки не
|
||||
`MISMATCH` и без предупреждений маппинга; иначе `NEEDS_CHECK`."""
|
||||
recognized = job_done and _checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings
|
||||
recognized = job_done and checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings
|
||||
return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
|
||||
|
||||
1. короткая транзакция: `pick_candidate` (`FOR UPDATE OF recognition_job SKIP LOCKED`) и проверка ключа результата
|
||||
(бакет хранилища, папка `results/{file_id}/{job_id}/`, OQ-70);
|
||||
2. БЕЗ транзакции: чтение объекта из S3 и валидация `RecognitionResult` v1.x — блокировки не держатся на время сети;
|
||||
2. БЕЗ транзакции: чтение объекта из S3 и валидация результата по MAJOR (`1.x` или `2.0`) — блокировки не
|
||||
держатся на время сети;
|
||||
3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox,
|
||||
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
|
||||
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
|
||||
@ -39,20 +40,22 @@ from sqlalchemy import Integer, Select, delete, func, insert, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.contract.result import OsvDocument, RecognitionResult, StatementDocument
|
||||
from src.contract.result_v2 import RecognitionResultV2, StatementDocumentV2
|
||||
from src.contract.result_v2 import DocumentWarning, OsvDocumentV2, RecognitionResultV2, StatementDocumentV2
|
||||
from src.contract.versions import parse_result
|
||||
from src.core.config import Settings
|
||||
from src.core.storage import ObjectNotFoundError, ObjectStorage
|
||||
from src.domain.osv_mapping import (
|
||||
EntryDraft,
|
||||
build_entries,
|
||||
checks_clean,
|
||||
counterparty_check,
|
||||
document_fields,
|
||||
document_status,
|
||||
opening_matches,
|
||||
)
|
||||
from src.domain.statement_issues import mapping_warning
|
||||
from src.domain.statement_issues import IssueDraft, mapping_warning
|
||||
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
|
||||
from src.domain.statement_status import duplicate_period_forms
|
||||
from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||
from src.models.directories import CompanyGroup, LegalEntity
|
||||
@ -60,9 +63,12 @@ from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
DocumentCheckResult,
|
||||
DocumentIssueKind,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
FileStatus,
|
||||
IssueSeverity,
|
||||
IssueSource,
|
||||
OsvDataType,
|
||||
OsvGroupType,
|
||||
PeriodQuarter,
|
||||
@ -75,7 +81,7 @@ from src.repositories.counterparties import CounterpartyIndex
|
||||
from src.services import consolidation_documents
|
||||
from src.services.audit import AuditService
|
||||
from src.services.file_status import set_file_status_for_job
|
||||
from src.services.statement_intake_v2 import FormV2, OtherV2, insert_forms_v2, map_statement_docs_v2
|
||||
from src.services.statement_intake_v2 import FormV2, OtherV2, file_warnings, insert_forms_v2, map_statement_docs_v2
|
||||
from src.services.statement_recompute import checks_on_intake_1x
|
||||
from src.services.statements_engine import StatementsEngine, default_engine
|
||||
|
||||
@ -204,16 +210,22 @@ def _osv_documents(result: RecognitionResult | RecognitionResultV2) -> list[OsvD
|
||||
return [document for document in result.documents if isinstance(document, OsvDocument)]
|
||||
|
||||
|
||||
async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult:
|
||||
"""Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`."""
|
||||
async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult | RecognitionResultV2:
|
||||
"""Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`.
|
||||
|
||||
Модель выбирается по MAJOR `schema_version` (`contract.versions.parse_result`): 1 — `RecognitionResult` (маппер
|
||||
`1.x`), 2 — `RecognitionResultV2` (маппер `2.0`); иной MAJOR — итог проверки схемы (P3-46). Нечитаемый JSON и
|
||||
не-JSON байты — тоже неповторяемая ошибка схемы (T5.2), без текста исключения в `mapping_error`."""
|
||||
try:
|
||||
content = await storage.get_bytes(candidate.key)
|
||||
except ObjectNotFoundError as exc:
|
||||
raise MappingFatal(f"файл результата не найден в S3: {candidate.bucket}/{candidate.key}") from exc
|
||||
try:
|
||||
result = RecognitionResult.model_validate_json(content)
|
||||
result = parse_result(content)
|
||||
except ValidationError as exc:
|
||||
raise MappingFatal(_schema_error_summary(exc)) from exc
|
||||
except ValueError as exc: # JSONDecodeError, UnicodeDecodeError: pydantic ValidationError перехвачен выше
|
||||
raise MappingFatal("результат не соответствует схеме recognition-result: файл не является JSON") from exc
|
||||
if result.job_id != candidate.job_id or result.file_id != candidate.file_id:
|
||||
raise MappingFatal(
|
||||
f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}"
|
||||
@ -316,6 +328,44 @@ async def _q1_root_opening(
|
||||
return (root.opening_debit, root.opening_credit) if root is not None else None
|
||||
|
||||
|
||||
def _osv_warning_issue(warning: DocumentWarning) -> IssueDraft:
|
||||
"""Предупреждение листа ОСВ `2.0` (`OSV_*`, `info`) → `document_issue` вида `warning`, источник `service`.
|
||||
У ОСВ нет строк и колонок формы: `line_refs` не сохраняются, `column_index` и `field` — как пришли."""
|
||||
return IssueDraft(
|
||||
kind=DocumentIssueKind.warning,
|
||||
code=warning.code,
|
||||
severity=IssueSeverity(warning.severity),
|
||||
message=warning.message,
|
||||
source=IssueSource.service,
|
||||
column_index=warning.column_index,
|
||||
field=warning.field,
|
||||
)
|
||||
|
||||
|
||||
def _osv_status(
|
||||
sheet: OsvDocument,
|
||||
*,
|
||||
job_done: bool,
|
||||
file_error: bool,
|
||||
counterparty: DocumentCheckResult,
|
||||
mapping_warnings: Sequence[str],
|
||||
) -> tuple[DocumentStatus, list[IssueDraft]]:
|
||||
"""Статус документа ОСВ и записи `document_issue` сервиса: `2.0` — по Р-28 (`osv_status_v2`, статус листа и
|
||||
предупреждения листа), `1.x` — по §9.3 (`document_status`, статус задания); у `1.x` записей сервиса нет."""
|
||||
if isinstance(sheet, OsvDocumentV2):
|
||||
status = osv_status_v2(
|
||||
osv_done=sheet.status == "done",
|
||||
checks_ok=checks_clean(sheet.checks),
|
||||
file_error_warning=file_error,
|
||||
counterparty=counterparty,
|
||||
sheet_mapping_warnings=bool(mapping_warnings),
|
||||
)
|
||||
return status, [_osv_warning_issue(warning) for warning in sheet.warnings]
|
||||
return document_status(
|
||||
job_done=job_done, checks=sheet.checks, counterparty=counterparty, warnings=mapping_warnings
|
||||
), []
|
||||
|
||||
|
||||
def _group_schema(sheet: OsvDocument) -> dict[str, Any]:
|
||||
return {
|
||||
"locator": sheet.locator,
|
||||
@ -559,7 +609,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
||||
await _clear_file(session, file.id)
|
||||
kept, duplicate_warnings, warnings = _split_duplicates(sheets)
|
||||
documents: list[Document] = []
|
||||
sheet_issues: list[tuple[Document, list[str]]] = []
|
||||
sheet_issues: list[tuple[Document, list[IssueDraft]]] = []
|
||||
file_error = isinstance(result, RecognitionResultV2) and has_error_warning(file_warnings(result))
|
||||
entries: list[dict[str, Any]] = []
|
||||
for sheet in kept:
|
||||
fields = document_fields(sheet)
|
||||
@ -591,6 +642,14 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
||||
check = counterparty_check(
|
||||
sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names
|
||||
)
|
||||
mapping_warnings = [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings]
|
||||
status, service_drafts = _osv_status(
|
||||
sheet,
|
||||
job_done=job_done,
|
||||
file_error=file_error,
|
||||
counterparty=check,
|
||||
mapping_warnings=mapping_warnings,
|
||||
)
|
||||
document = Document(
|
||||
id=uuid.uuid4(),
|
||||
stored_file_id=file.id,
|
||||
@ -602,19 +661,14 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
||||
period_quarter=fields.period_quarter,
|
||||
is_cumulative=is_cumulative,
|
||||
locator=fields.locator,
|
||||
status=document_status(
|
||||
job_done=job_done,
|
||||
checks=sheet.checks,
|
||||
counterparty=check,
|
||||
warnings=[*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings],
|
||||
),
|
||||
status=status,
|
||||
period_check=DocumentCheckResult.UNKNOWN, # до версии консолидации (Срез 4)
|
||||
counterparty_check=check,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
documents.append(document)
|
||||
sheet_issues.append((document, [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings]))
|
||||
sheet_issues.append((document, [*service_drafts, *(mapping_warning(text) for text in mapping_warnings)]))
|
||||
warnings.extend(sheet_warnings)
|
||||
entries.extend(
|
||||
_entry_values(
|
||||
@ -632,8 +686,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
||||
|
||||
session.add_all(documents)
|
||||
await session.flush()
|
||||
for document, messages in sheet_issues: # P3-5: предупреждения листа ОСВ — и в `document_issue`
|
||||
await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in messages], now=now)
|
||||
for document, drafts in sheet_issues: # P3-5: предупреждения листа ОСВ (сервис 2.0 и маппинг) — в `document_issue`
|
||||
await statements_repo.add_issues(session, document.id, drafts, now=now)
|
||||
# T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк
|
||||
# результата; сортировка устойчивая, внутри уровня порядок строк сохраняется
|
||||
entries.sort(key=lambda values: values["level"])
|
||||
|
||||
@ -34,7 +34,7 @@ from src.repositories import statements as statements_repo
|
||||
from src.services.statements_engine import DocStatus, FormVariant, StatementsEngine
|
||||
from src.services.statements_engine import DocumentType as PackageDocumentType
|
||||
|
||||
__all__ = ["FormV2", "OtherV2", "insert_forms_v2", "map_statement_docs_v2"]
|
||||
__all__ = ["FormV2", "OtherV2", "file_warnings", "insert_forms_v2", "map_statement_docs_v2"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -128,7 +128,7 @@ def map_statement_docs_v2( # noqa: PLR0913 - все входы именован
|
||||
return forms, others, warnings, repeats
|
||||
|
||||
|
||||
def _file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]:
|
||||
def file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]:
|
||||
"""`metrics.warnings` файла результата структурами (P3-15)."""
|
||||
if result.metrics is None or result.metrics.warnings is None:
|
||||
return []
|
||||
@ -148,7 +148,7 @@ async def insert_forms_v2( # noqa: PLR0913 - все входы именован
|
||||
) -> None:
|
||||
"""Форма как пришла, `document_issue` и статус каждого документа формы; предупреждения `DISCLOSURE` — в
|
||||
`document_issue` его единственного документа. Без commit."""
|
||||
file_error = has_error_warning(_file_warnings(result))
|
||||
file_error = has_error_warning(file_warnings(result))
|
||||
catalog = load_line_catalog()
|
||||
for other in others:
|
||||
drafts: list[IssueDraft] = []
|
||||
|
||||
@ -483,3 +483,28 @@ async def test_period_quarter_enum_in_card(seeded: AsyncSession, auth_client: Au
|
||||
body = (await client.get(f"/documents/{document.id}")).json()
|
||||
|
||||
assert (body["period_quarter"], body["is_cumulative"]) == ("Q3", False)
|
||||
|
||||
|
||||
async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Предупреждения листа ОСВ результата `2.0` (`OSV_*`) — в `DocumentOut.issues` (источник `service`, `info`)."""
|
||||
# ленивые импорты: `tests.fixtures_helpers` тяжёлый и нужен одному тесту
|
||||
from src.contract import RecognitionResultV2 # noqa: PLC0415
|
||||
from tests.fixtures_helpers import map_golden_snapshot # noqa: PLC0415
|
||||
|
||||
def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2:
|
||||
example = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-needs-check.json"
|
||||
payload = json.loads(example.read_text("utf-8"))
|
||||
return RecognitionResultV2.model_validate({**payload, "job_id": str(job_id), "file_id": str(file_id)})
|
||||
|
||||
entity = await _rostov_entity(seeded)
|
||||
(document,) = await map_golden_snapshot(
|
||||
seeded, entity=entity, path=Path("result-v2.json"), uploaded_by=None, result_builder=build
|
||||
)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
body = (await client.get(f"/documents/{document.id}")).json()
|
||||
|
||||
service = [issue for issue in body["issues"] if issue["source"] == "service"]
|
||||
assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"}
|
||||
assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service)
|
||||
assert body["status"] == "NEEDS_CHECK"
|
||||
|
||||
@ -430,3 +430,100 @@ async def test_value_cells_and_empty_records(seeded: AsyncSession) -> None:
|
||||
empty = cells[(stored[0].id, 3)]
|
||||
assert (empty.amount, empty.source_text, empty.needs_review) == (None, None, False)
|
||||
assert cells[(stored[1].id, 2)].needs_review is True
|
||||
|
||||
|
||||
# --- ОСВ по контракту 2.0 (B5, Р-28) --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _osv_doc(
|
||||
example: str = "result-osv-minimal.json", *, account: str = "60", locator: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Лист ОСВ из примера docs; `account` — счёт шапки, `locator` — уникальный `locator` листа."""
|
||||
doc = json.loads((EXAMPLES / example).read_text("utf-8"))["documents"][0]
|
||||
doc["header"]["account_code_raw"] = account
|
||||
if locator is not None:
|
||||
doc["locator"] = locator
|
||||
return doc
|
||||
|
||||
|
||||
async def _osv_issues(session: AsyncSession, document: Document) -> dict[str, DocumentIssue]:
|
||||
return {issue.code: issue for issue in await _issues(session, document)}
|
||||
|
||||
|
||||
async def test_osv_in_book_with_needs_check_income(seeded: AsyncSession) -> None:
|
||||
"""Сценарий спеки: ОСВ `done` с сошедшимися суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет →
|
||||
ОСВ `CONFIRMED` (автоподтверждение), её `OSV_*` — в `document_issue`."""
|
||||
entity = await _entity(seeded)
|
||||
osv = _osv_doc()
|
||||
osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "info", "message": "иерархия по outline"}]
|
||||
income = statement_doc_v2("INCOME", status="needs_check", lines=INCOME_LINES)
|
||||
documents = await _map(seeded, entity, [osv, income], status="needs_check")
|
||||
osv_document, income_document = documents
|
||||
assert osv_document.document_type is DocumentType.OSV and income_document.status is DocumentStatus.NEEDS_CHECK
|
||||
assert osv_document.status is DocumentStatus.RECOGNIZED
|
||||
issue = (await _osv_issues(seeded, osv_document))["OSV_HIERARCHY_BY_OUTLINE"]
|
||||
assert issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
|
||||
assert issue.severity.value == "info" and issue.message == "иерархия по outline"
|
||||
await _confirm_clean(seeded, [osv_document])
|
||||
assert osv_document.status is DocumentStatus.CONFIRMED
|
||||
|
||||
|
||||
async def test_osv_v2_examples(seeded: AsyncSession) -> None:
|
||||
"""Примеры docs: `result-osv-minimal.json` — `RECOGNIZED`; `result-osv-needs-check.json` (`status` листа
|
||||
`needs_check`, нарушение тождества) — `NEEDS_CHECK`, оба `OSV_*` в issues."""
|
||||
entity = await _entity(seeded)
|
||||
await map_golden_snapshot(
|
||||
seeded,
|
||||
entity=entity,
|
||||
path=V2_PATH,
|
||||
uploaded_by=None,
|
||||
result_builder=_example_builder("result-osv-minimal.json"),
|
||||
)
|
||||
await map_golden_snapshot(
|
||||
seeded,
|
||||
entity=entity,
|
||||
path=V2_PATH,
|
||||
uploaded_by=None,
|
||||
result_builder=_example_builder("result-osv-needs-check.json"),
|
||||
)
|
||||
rows = (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id))).scalars().all()
|
||||
by_status = {document.status: document for document in rows}
|
||||
assert set(by_status) == {DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}
|
||||
assert not await _issues(seeded, by_status[DocumentStatus.RECOGNIZED])
|
||||
codes = set(await _osv_issues(seeded, by_status[DocumentStatus.NEEDS_CHECK]))
|
||||
assert {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} <= codes
|
||||
|
||||
|
||||
async def test_two_osv_v2_ordinals(seeded: AsyncSession) -> None:
|
||||
"""Два листа ОСВ (60 и 62) в одном файле: `file_ordinal` 0 и 1."""
|
||||
entity = await _entity(seeded)
|
||||
documents = await _map(
|
||||
seeded, entity, [_osv_doc(account="60", locator="sheet:A"), _osv_doc(account="62", locator="sheet:B")]
|
||||
)
|
||||
assert [(d.osv_account_code, d.file_ordinal) for d in documents] == [("60", 0), ("62", 1)]
|
||||
|
||||
|
||||
async def test_osv_v2_error_file_warning_blocks_recognized(seeded: AsyncSession) -> None:
|
||||
"""Предупреждение файла `error` (Р-28) — ОСВ `NEEDS_CHECK`, хотя лист `done` и суммы сошлись."""
|
||||
entity = await _entity(seeded)
|
||||
(document,) = await _map(seeded, entity, [_osv_doc()], file_warnings=[ERROR_JSON_FAILED])
|
||||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||||
|
||||
|
||||
async def test_osv_v2_mapping_warning_blocks_recognized(seeded: AsyncSession) -> None:
|
||||
"""Повтор счёта в файле — предупреждение маппинга по листу: первый лист `NEEDS_CHECK`, запись в issues
|
||||
(источник `core`, `MAPPING_WARNING`)."""
|
||||
entity = await _entity(seeded)
|
||||
(document,) = await _map(seeded, entity, [_osv_doc(locator="sheet:A"), _osv_doc(locator="sheet:B")])
|
||||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||||
issue = (await _osv_issues(seeded, document))["MAPPING_WARNING"]
|
||||
assert issue.source is IssueSource.core and "sheet:B" in issue.message
|
||||
|
||||
|
||||
async def test_osv_v2_sheet_needs_check_blocks_recognized(seeded: AsyncSession) -> None:
|
||||
"""`osvDocument.status = needs_check` при чистых суммах — `NEEDS_CHECK` (статус конверта не важен)."""
|
||||
entity = await _entity(seeded)
|
||||
osv = _osv_doc()
|
||||
osv["status"] = "needs_check"
|
||||
(document,) = await _map(seeded, entity, [osv])
|
||||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||||
|
||||
@ -1981,3 +1981,56 @@ async def test_osv_orphans_mapping_warning_issue(seeded: AsyncSession) -> None:
|
||||
assert (orphans.kind.value, orphans.severity.value, orphans.source.value) == ("warning", "info", "core")
|
||||
job = await seeded.get(RecognitionJob, file.current_recognition_job_id)
|
||||
assert job is not None and any("без родителя" in w for w in job.metrics[MAPPING_WARNINGS_KEY])
|
||||
|
||||
|
||||
# --- приём файла результата: выбор по MAJOR, нечитаемый JSON (B5) ---------------------------------------------------
|
||||
|
||||
|
||||
def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str:
|
||||
return f"results/{file_id}/{job_id}/result.json"
|
||||
|
||||
|
||||
async def test_result_major_3_is_fatal(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""P3-46: неизвестный MAJOR (`3.0`) — неповторяемая ошибка схемы: файл `ERROR`, без документов и повтора."""
|
||||
maker, storage = committed_sessionmaker, FakeStorage()
|
||||
file_id, job_id, _ = await _prepare(maker, storage, _minimal() | {"schema_version": "3.0"})
|
||||
|
||||
assert await process_once(maker, storage, _settings()) is True
|
||||
|
||||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||||
job = await _job(maker, job_id)
|
||||
assert job.metrics[MAPPING_ERROR_KEY].startswith("результат не соответствует схеме")
|
||||
assert MAPPING_ATTEMPTS_KEY not in job.metrics
|
||||
assert await _documents(maker, file_id) == []
|
||||
assert await process_once(maker, storage, _settings()) is False
|
||||
|
||||
|
||||
async def test_result_1x_still_mapped(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""MAJOR 1 (`1.2`) — маппер `1.x`: документ ОСВ создан, файл `PROCESSED`."""
|
||||
maker, storage = committed_sessionmaker, FakeStorage()
|
||||
file_id, _, _ = await _prepare(maker, storage, _minimal() | {"schema_version": "1.2"}, owner_inn="7700000000")
|
||||
|
||||
assert await process_once(maker, storage, _settings()) is True
|
||||
|
||||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
|
||||
assert len(await _documents(maker, file_id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"{not json", b"", b"\xff\xfe\x00", b"[1, 2]", b"null"])
|
||||
async def test_unreadable_result_json_is_fatal(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession], content: bytes
|
||||
) -> None:
|
||||
"""T5.2: невалидный JSON / не объект — `MappingFatal` («результат не соответствует схеме…»): файл `ERROR`,
|
||||
без повтора, без текста исключения парсера в `mapping_error`."""
|
||||
maker, storage = committed_sessionmaker, FakeStorage()
|
||||
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
|
||||
storage.objects[_result_key(file_id, job_id)] = content
|
||||
|
||||
assert await process_once(maker, storage, _settings()) is True
|
||||
|
||||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||||
job = await _job(maker, job_id)
|
||||
assert job.metrics[MAPPING_ERROR_KEY].startswith("результат не соответствует схеме recognition-result")
|
||||
assert "Expecting" not in job.metrics[MAPPING_ERROR_KEY]
|
||||
assert MAPPING_ATTEMPTS_KEY not in job.metrics
|
||||
assert await process_once(maker, storage, _settings()) is False
|
||||
|
||||
@ -54,3 +54,7 @@ Goals и Non-goals — см. `proposal.md`.
|
||||
| 2026-10-18 | **P3-43 (B4).** Результат с повтором `row_index` или `ref` у строк: вторая и далее строка пропускается с `MAPPING_WARNING`; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null` и тем же предупреждением | уникальность `(meta_id, source_ref)` и ключа строки при вставке иначе роняет приём (файл `ERROR` после попыток); схема уникальности `ref`/`row_index` не проверяет | потерянная строка дубля видна только в предупреждении |
|
||||
| 2026-10-18 | **P3-44 (B4).** Запись `relation` / `soft` из `checks` результата несёт `column_id` (колонка с таким `column_index`, если есть) и всегда `column_index` (как P3-33); запись `warning` с `column_index` — либо `column_id` (колонка есть), либо `column_index` (колонки нет) | спека «Пропущенная колонка» и ответ API, читающий индекс из записи | нет |
|
||||
| 2026-10-18 | **P3-45 (B4).** `is_current` колонок `2.0` — как у `1.x` (колонка с самой поздней датой, при равенстве меньший `column_index`; нет дат — `NULL`), в том числе у колонок `relative:*` с датой; значение, которого нет в результате, пишется с `is_recognized = NULL`, остальными полями пустыми и `needs_review = false` | §9.5 п. 2; спека «Хранение результата формы v2.0» | колонка `relative:*` без даты не станет текущей |
|
||||
| 2026-10-18 | **P3-46 (B5).** `_load_result` выбирает модель по MAJOR через `contract.versions.parse_result`; неизвестный MAJOR, неверный формат версии и любое нарушение схемы — `MappingFatal` с итогом проверки схемы (`результат не соответствует схеме recognition-result: ошибок N, первая: …`), файл `ERROR`, без повтора | спека «Приём файла результата»; D-2 | файл с MAJOR 3 не мапится, а не обрабатывается молча как `1.x` |
|
||||
| 2026-10-18 | **P3-47 (B5, T5.2 части А).** Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы | иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток | ошибка JSON не отличается в тексте от прочих ошибок схемы |
|
||||
| 2026-10-18 | **P3-48 (B5).** Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics` | Р-28; единый источник с формами | ОСВ с `error` в метриках файла — `NEEDS_CHECK` |
|
||||
| 2026-10-18 | **P3-49 (B5).** `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист | у ОСВ нет `line_ids`/колонок формы; повтор — D-7 | `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа) |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user