diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index f367562..e9fc062 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -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 diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index e5d4971..4ee7802 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -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"]) diff --git a/api/src/services/statement_intake_v2.py b/api/src/services/statement_intake_v2.py index e44e668..f623e51 100644 --- a/api/src/services/statement_intake_v2.py +++ b/api/src/services/statement_intake_v2.py @@ -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] = [] diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index cb868f8..899eed2 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -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" diff --git a/api/tests/test_mapping_statements_v2.py b/api/tests/test_mapping_statements_v2.py index 465fa7f..c331325 100644 --- a/api/tests/test_mapping_statements_v2.py +++ b/api/tests/test_mapping_statements_v2.py @@ -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 diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index d0c7615..d05c3a0 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -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 diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 9c232ae..35cfd2d 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -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` листа) |