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:
Andrey Epifancev 2026-10-18 21:57:38 +04:00
parent 5e45a6ca7e
commit 2b8fafc3b3
7 changed files with 258 additions and 23 deletions

View File

@ -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

View File

@ -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"])

View File

@ -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] = []

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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` листа) |