diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json index fcee529..260528b 100644 --- a/api/contracts/examples/result-statement-excel.json +++ b/api/contracts/examples/result-statement-excel.json @@ -3,7 +3,7 @@ "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "service": { "name": "tabular-parser", "version": "0.3.0" }, - "status": "needs_check", + "status": "done", "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, "documents": [ { diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 51e3b17..913cfc5 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -44,8 +44,7 @@ { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } ] } - ], - "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + ] } ] } diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index 9a168cf..43781b3 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -3,7 +3,7 @@ "$id": "arm-ka/recognition-result/v1", "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { @@ -210,7 +210,7 @@ "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", "properties": { "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, - "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе»): только предупреждение" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } } } diff --git a/api/rules/README.md b/api/rules/README.md index 337e903..9f8d562 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -16,7 +16,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | | `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке `min_codes`, ослабленный минимум для листа-продолжения после формы `min_codes_continuation`), `okud_code` (код ОКУД внутри ячейки), `units_thousands` (единицы в тысячах), `fractional_share_percent` (порог доли значений с дробной частью при «тыс. руб.», процент 1..100: при превышении — одно предупреждение на документ; на статус не влияет) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) | | `statement_periods.yaml` | `months` (названия месяцев, группы `m01…m12`), `on_date` (дата вида «на 31 декабря 2024 г.», группы `d`, `m`, `y`), `numeric_date` (дата вида «31.12.2024», группы `d`, `m`, `y`), `for_months` (период вида «за 9 месяцев 2025 г.», группы `n`, `y`), `for_range` (диапазон месяцев вида «за январь–сентябрь 2025 г.», группы `m`, `y`), `for_year` (год вида «за 2024 г.» или «2024») | текст заголовка колонки или подзаголовка формы ББ/ОФР -> дата конца периода (§12 п. 4) | -| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) | +| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `rounding_tolerance_percent` (OQ-138: доля в процентах от наибольшего по модулю числа соотношения; жёсткое расхождение в её пределах — округление итогов, уходит в мягкие с предупреждением «в пределах округления»; сейчас 0,001), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) | ## Как писать паттерны diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml index 7ab146c..8f2effa 100644 --- a/api/rules/statement_checks.yaml +++ b/api/rules/statement_checks.yaml @@ -3,6 +3,11 @@ # (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в # скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) +# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление. +# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа +# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус: +# уходит в soft_relations_failed с предупреждением «в пределах округления». +rounding_tolerance_percent: 0.001 subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду relations: - {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} diff --git a/api/src/parser/book.py b/api/src/parser/book.py index ca308f9..918df22 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -100,20 +100,31 @@ class BookParser: osv_documents: list[OsvDocument] = [] statement_sheets: list[tuple[Sheet, SheetKind]] = [] ok = True + previous_is_statement = False # N-2: слабое продолжение — только сразу после листа формы for sheet in sheets: deadline.check(f"лист «{sheet.name}»") kind = classify_sheet(sheet, self._rules) if kind in STATEMENT_KINDS: statement_sheets.append((sheet, kind)) + previous_is_statement = True continue - if kind is SheetKind.OTHER and statement_sheets and is_weak_continuation(sheet, self._rules): - kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами после листа формы + if kind is SheetKind.OTHER and previous_is_statement and is_weak_continuation(sheet, self._rules): + kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами сразу после листа формы statement_sheets.append((sheet, kind)) continue + previous_is_statement = False if kind is SheetKind.OTHER: warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) continue - document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) + try: + document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) + except ParserError: + # T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными + warnings.extend( + sheet_line(form_sheet, f"форма не разобрана: книга завершилась ошибкой листа ОСВ «{sheet.name}»") + for form_sheet, _ in statement_sheets + ) + raise osv_documents.append(document) warnings.extend(sheet_warnings) ok = ok and sheet_ok diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index d46e3e4..8861b24 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -547,12 +547,20 @@ def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]: class StatementCheckRules(_Model): tolerance: Decimal + #: OQ-138: доля (в процентах) от наибольшего |числа| соотношения, в пределах которой жёсткое нарушение — округление + rounding_tolerance_percent: Decimal = Decimal("0") subline_check: bool = True relations: list[RelationRule] def relations_for(self, form: str) -> list[RelationRule]: return [rule for rule in self.relations if rule.form == form] + @model_validator(mode="after") + def _check_rounding_tolerance(self) -> "StatementCheckRules": + if not Decimal("0") <= self.rounding_tolerance_percent <= Decimal("100"): + raise ValueError("rounding_tolerance_percent: ожидается доля в процентах от 0 до 100") + return self + @model_validator(mode="after") def _check_explicit_rules_have_four_digit_codes(self) -> "StatementCheckRules": """Явные правила из yaml содержат только четырёхзначные коды; пятизначные — только в подстроках.""" diff --git a/api/src/parser/statements/checks.py b/api/src/parser/statements/checks.py index 3daa136..338b199 100644 --- a/api/src/parser/statements/checks.py +++ b/api/src/parser/statements/checks.py @@ -4,6 +4,9 @@ и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `tolerance` для всех правил. Жёсткие нарушения → `relations_failed`, мягкие → `soft_relations_failed`. Правило проверяется, если целевой код в данных (подстроки всегда). +OQ-138: жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего |числа| соотношения — округление, +уходит в мягкие. +M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же первыми тремя цифрами. """ from collections.abc import Sequence @@ -15,7 +18,9 @@ from src.parser.rules import FormType, RelationRule, Rules __all__ = ["run_statement_checks"] _ZERO = Decimal("0") +_HUNDRED = Decimal("100") _PARENT_LEN = 4 +_GROUP_LEN = 3 # первые цифры кода, общие у строки и её подстрок (1230 → 12301…12314) def _amounts(lines: Sequence[StatementLine], column_count: int) -> list[dict[str, Decimal]]: @@ -63,14 +68,28 @@ def _subline_rules(lines: Sequence[StatementLine], form: FormType, rules: Rules) есть пятизначные подстроки.""" low, high = rules.statement_headers.code_ranges[form] codes = [item.line_code for item in lines] - rules_out: list[RelationRule] = [] - for parent in codes: - if len(parent) != _PARENT_LEN or not low <= int(parent) <= high: + parents = {code for code in codes if len(code) == _PARENT_LEN} + children: dict[str, list[str]] = {} + last_parent: str | None = None + for code in codes: # порядок строк документа + if len(code) == _PARENT_LEN: + last_parent = code continue - children = [code for code in codes if len(code) == _PARENT_LEN + 1 and code.startswith(parent)] - if children: - rules_out.append(RelationRule(form=form, relation=f"{parent} = {' + '.join(children)}", strict=False)) - return rules_out + if len(code) != _PARENT_LEN + 1: + continue + # M-9: 12310 под 1230 — по порядку строк; без подходящей строки выше — по префиксу (11101 → 1110) + if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]: + parent = last_parent + elif code[:_PARENT_LEN] in parents: + parent = code[:_PARENT_LEN] + else: + continue + children.setdefault(parent, []).append(code) + return [ + RelationRule(form=form, relation=f"{parent} = {' + '.join(kids)}", strict=False) + for parent, kids in children.items() + if low <= int(parent) <= high + ] def run_statement_checks( @@ -94,6 +113,16 @@ def run_statement_checks( if holds: continue failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual) + # база — наибольшее по модулю число соотношения: итог может быть малой разностью больших слагаемых + scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)]) + rounding = scale * config.rounding_tolerance_percent / _HUNDRED + if rule.strict and abs(expected - actual) <= rounding: + soft.append(failed) + warnings.append( + f"соотношение «{rule.relation}» сошлось в пределах округления (колонка {column_index}): " + f"{expected} ≈ {actual}" + ) + continue if rule.strict: hard.append(failed) warning_msg = f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}" diff --git a/api/src/parser/statements/columns.py b/api/src/parser/statements/columns.py index a9d5c64..69b07c8 100644 --- a/api/src/parser/statements/columns.py +++ b/api/src/parser/statements/columns.py @@ -276,6 +276,21 @@ def _note_columns(sheet: Sheet, rules: Rules, code_col: int, header_row: int, he ) +def _short_value_columns( # noqa: PLR0913, PLR0917 - сигнатура как у _name_column + sheet: Sheet, rules: Rules, code_col: int, name_col: int | None, first_row: int, last_row: int +) -> tuple[int, ...]: + """N-4: без шапки «Пояснения» узнаются по содержимому — левее кодов, не колонка названий, в строках с кодами только + короткие значения («5.1», «2»).""" + short: dict[int, bool] = {} + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is None: + continue + for col, cell in sheet.row_cells(row): + if col < code_col and col != name_col: + short[col] = short.get(col, True) and len(cell.text) <= _SHORT_TEXT + return tuple(col for col, is_short in sorted(short.items()) if is_short) + + def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, relaxed: bool = False) -> TableLayout: code_col = find_code_column(sheet, rules, first_row, last_row, relaxed=relaxed) if code_col is None: @@ -285,7 +300,8 @@ def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, re if header_row is None: periods = _value_columns(sheet, rules, code_col, first_row, last_row) name_col = _name_column(sheet, rules, code_col, None, first_row, last_row) - return TableLayout(None, None, code_col, name_col, periods, (), first_row) + notes = _short_value_columns(sheet, rules, code_col, name_col, first_row, last_row) + return TableLayout(None, None, code_col, name_col, periods, (), first_row, notes) header_last_row = _header_last_row(sheet, rules, header_row, code_col, first_code_row) periods, skipped = _period_columns(sheet, rules, header_row, header_last_row, code_col) name_col = _name_column(sheet, rules, code_col, header_row, header_last_row + 1, last_row) diff --git a/api/src/parser/statements/statement.py b/api/src/parser/statements/statement.py index ee07bf8..8b27adb 100644 --- a/api/src/parser/statements/statement.py +++ b/api/src/parser/statements/statement.py @@ -10,7 +10,7 @@ from src.contract.result import StatementColumn, StatementDocument, StatementHea from src.parser.deadline import Deadline from src.parser.detect import SheetKind from src.parser.grid import Sheet -from src.parser.rules import Rules +from src.parser.rules import FormType, Rules from src.parser.statements.checks import run_statement_checks from src.parser.statements.columns import NO_CODE_COLUMN, LayoutNotFoundError, TableLayout, find_layout, line_code from src.parser.statements.detect import FormBlock, continuation_form, find_forms @@ -89,16 +89,15 @@ class StatementParser: return False marker_kind, _ = continuation_form(sheet, self._rules) open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None) - if weak and (open_draft is None or not can_join(open_draft, layout, marker_kind)): - blocker = ( - "нет открытого документа формы" if open_draft is None else join_blocker(open_draft, layout, marker_kind) - ) - codes = sum( - 1 for row in range(1, sheet.n_rows + 1) if line_code(sheet.cell(row, layout.code_col), self._rules) - ) - minimum = self._rules.statement_headers.min_codes - warnings.append(_line(sheet, f"лист с кодами форм ({codes} < {minimum}) не присоединён: {blocker}")) - return False + weak_codes = self._sheet_codes(sheet, layout) if weak else [] + if weak: + blocker = self._weak_blocker(open_draft, layout, marker_kind, weak_codes) + if blocker is not None: + minimum = self._rules.statement_headers.min_codes + warnings.append( + _line(sheet, f"лист с кодами форм ({len(weak_codes)} < {minimum}) не присоединён: {blocker}") + ) + return False if open_draft is not None and can_join(open_draft, layout, marker_kind): result = collect_lines( sheet, self._rules, layout, layout.first_data_row, sheet.n_rows, units=open_draft.header.units @@ -107,6 +106,10 @@ class StatementParser: open_draft.fractional_values += result.fractional_values open_draft.numeric_values += result.numeric_values warnings.extend(_line(sheet, text) for text in result.warnings) + if weak: # N-1: присоединение по 1–2 строкам видно аналитику + warnings.append( + _line(sheet, f"присоединён к документу как продолжение по {len(weak_codes)} кодовой строке") + ) return True kind = marker_kind header, _ = parse_header(sheet, self._rules, 1, (layout.header_row or layout.first_data_row) - 1, None) @@ -118,6 +121,26 @@ class StatementParser: drafts.append(draft) return True + def _sheet_codes(self, sheet: Sheet, layout: TableLayout) -> list[str]: + cells = (line_code(sheet.cell(row, layout.code_col), self._rules) for row in range(1, sheet.n_rows + 1)) + return [code for code in cells if code is not None] + + def _weak_blocker( + self, open_draft: Draft | None, layout: TableLayout, marker_kind: FormType | None, codes: list[str] + ) -> str | None: + """Почему лист с 1–2 кодами нельзя присоединить (N-1): нет открытого документа, общие причины `join_blocker`, + коды не из диапазона формы открытого документа (лист «Справка» с кодом баланса после ОФР).""" + if open_draft is None: + return "нет открытого документа формы" + blocker = join_blocker(open_draft, layout, marker_kind) + if blocker is not None or open_draft.kind not in self._rules.statement_headers.code_ranges: + return blocker + low, high = self._rules.statement_headers.code_ranges[open_draft.kind] + foreign = [code for code in codes if not low <= int(code[:4]) <= high] + if foreign: + return f"коды {', '.join(foreign)} не из формы {open_draft.kind}" + return None + def _fill( # noqa: PLR0913, PLR0917 - все части документа передаются явно self, sheet: Sheet, diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 7a5cc75..529f841 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -10,7 +10,7 @@ Spec tabular-recognition-service: «Ack раньше результата», « обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия», «Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO), -«Реальные формы через воркер» (книга НПЗ → `needs_check`, документы BALANCE и INCOME, `result_schema_version 1.2`). +«Реальные формы через воркер» (книга НПЗ → `done`, документы BALANCE и INCOME, `result_schema_version 1.2`). Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с @@ -323,10 +323,10 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file( @pytest.mark.fixtures -async def test_real_statements_through_worker_give_needs_check_with_two_documents( +async def test_real_statements_through_worker_give_done_with_two_documents( core: Core, uploads: Uploads, fixtures_dir: Path ) -> None: - """«Реальные формы через воркер»: книга НПЗ → ack → `needs_check` (рубли при шапке «тыс. руб.», OQ-138).""" + """«Реальные формы через воркер»: книга НПЗ → ack → `done`; расхождения итогов — округление (OQ-138, мягкие).""" request = _new_request(key_name="npz_2025_q3.xlsx") request["file_type"] = "XLSX" await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE) @@ -335,7 +335,7 @@ async def test_real_statements_through_worker_give_needs_check_with_two_document _assert_ack(await core.next_message(request), request) body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}") - assert body["status"] == "needs_check", body + assert body["status"] == "done", body assert body["result_schema_version"] == "1.2" result_ref = body["result_s3"] uploads.keys.append((result_ref["bucket"], result_ref["key"])) @@ -343,15 +343,13 @@ async def test_real_statements_through_worker_give_needs_check_with_two_document stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"])) assert schema_errors(stored) == [] assert RecognitionResult.model_validate(stored).to_wire() == stored - assert stored["status"] == "needs_check" + assert stored["status"] == "done" balance, income = stored["documents"] assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"] - assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"} - assert {item["relation"] for item in income["checks"]["relations_failed"]} == { - "2100 = 2110 - 2120", - "2200 = 2100 - 2210 - 2220", - "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", - } + assert balance["checks"]["relations_failed"] == [] and income["checks"]["relations_failed"] == [] + # расхождения в пределах округления (OQ-138) видны аналитику как мягкие + assert "1600 = 1700" in {item["relation"] for item in balance["checks"]["soft_relations_failed"]} + assert "2100 = 2110 - 2120" in {item["relation"] for item in income["checks"]["soft_relations_failed"]} # лист с единственной строкой 2400 присоединён (O-9) assert "2400" in {line["line_code"] for line in income["lines"]} await core.assert_quiet(request) diff --git a/api/tests/statements_manifest.yaml b/api/tests/statements_manifest.yaml index f3f0404..3d28632 100644 --- a/api/tests/statements_manifest.yaml +++ b/api/tests/statements_manifest.yaml @@ -13,7 +13,7 @@ files: organization: 'Акционерное общество "Унагранде Компани"' inn: "3249004697" documents: - - {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., итоги 2100/2200/2300 округлены до тысяч и расходятся с суммой частей (OQ-138)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + - {type: INCOME, status: done, complete: true, failed_relations: []} # итоги округлены до тысяч: мягкие нарушения «в пределах округления» (OQ-138) "unagrande-co/2024-FY/ОФР. Лист 2 .xls": documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] "unagrande-group/2024-FY/ББ. Лист 1 .xls": @@ -34,7 +34,7 @@ files: organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" documents: - - {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., 2100 расходится на 17.50/9.43 руб., 2200/2300 — округление итогов до тысяч (OQ-138)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + - {type: INCOME, status: done, complete: true, failed_relations: []} # 2200 — малая разность больших слагаемых, округление (OQ-138) "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": @@ -42,8 +42,8 @@ files: inn: "2315995868" inn_optional_for: [INCOME] documents: - - {type: BALANCE, status: needs_check, complete: false, sheets: 5, reason: "1600 = 1700 расходится на 1.94/9.02/2.48 руб.: значения в рублях при шапке тыс. руб. (OQ-138)", failed_relations: ["1600 = 1700"]} - - {type: INCOME, status: needs_check, complete: false, sheets: 3, reason: "по факту: рубли с копейками при шапке тыс. руб., 2100/2200/2300 расходятся с округлёнными итогами (OQ-138); лист продолжения с единственной строкой 2400 присоединён (O-9)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # 1600 = 1700 на 1.94/9.02/2.48 — округление (OQ-138) + - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # округление итогов (OQ-138); лист с единственной строкой 2400 присоединён (O-9) "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' inn: "7016006635" diff --git a/api/tests/unit/parser/statements/test_checks.py b/api/tests/unit/parser/statements/test_checks.py index 2d7cde2..95b9ab4 100644 --- a/api/tests/unit/parser/statements/test_checks.py +++ b/api/tests/unit/parser/statements/test_checks.py @@ -232,3 +232,71 @@ def test_failed_relation_keeps_as_written_variant_on_a_tie(rules: Rules) -> None checks, _ = run_statement_checks(lines, 1, "INCOME", rules) assert {item.relation: item.actual for item in checks.relations_failed}["2100 = 2110 - 2120"] == Decimal("1000") + + +# --- OQ-138 (решение владельца 06.10.2026): расхождение в пределах округления не портит статус --- + + +def test_rounding_within_relative_tolerance_is_soft_not_hard(rules: Rules) -> None: + """Итог 2 871 145 000 при сумме слагаемых 2 871 144 583.67: 416.33 ≤ 0,001 % итога — мягкое нарушение.""" + lines = [line("2110", "9033211892.66"), line("2120", "-6162067308.99"), line("2100", "2871145000")] + + checks, warnings = run_statement_checks(lines, 1, "INCOME", rules) + + assert checks.relations_failed == [] + assert [item.relation for item in checks.soft_relations_failed] == ["2100 = 2110 - 2120"] + assert any("в пределах округления" in warning for warning in warnings) + + +def test_discrepancy_above_relative_tolerance_stays_hard(rules: Rules) -> None: + """0,001 % от 1 000 000 = 10: расхождение 11 — жёсткое нарушение.""" + lines = [ + line("1150", "600000"), + line("1100", "600000"), + line("1210", "400011"), + line("1200", "400011"), + line("1600", "1000000"), + ] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert "1600 = 1100 + 1200" in [item.relation for item in checks.relations_failed] # 1600 = 1700: пассива нет + + +def test_absolute_tolerance_still_applies_to_small_totals(rules: Rules) -> None: + """У малых итогов действует абсолютный допуск ±1: 0,001 % от 150 меньше единицы.""" + lines = [line("1150", "100"), line("1100", "100"), line("1210", "50"), line("1200", "50"), line("1600", "151")] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + relations = [item.relation for item in [*checks.relations_failed, *checks.soft_relations_failed]] + assert "1600 = 1100 + 1200" not in relations # 1600 = 1700 нарушено: пассива нет + + +# --- M-9: подстроки привязываются к ближайшей четырёхзначной строке выше --- + + +def test_sublines_from_tenth_number_belong_to_parent_above(rules: Rules) -> None: + """У НПЗ под 1230 идут 12301…12314: 12310 начинается с «1231», но относится к 1230 по порядку строк.""" + lines = [line("1230", "20"), line("12301", "1"), line("12309", "4"), line("12310", "4"), line("12314", "5")] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert [item.relation for item in checks.soft_relations_failed] == ["1230 = 12301 + 12309 + 12310 + 12314"] + assert checks.soft_relations_failed[0].actual == Decimal("14") + + +def test_rounding_base_is_the_largest_number_of_the_relation(rules: Rules) -> None: + """ОФР Унагранде Групп 3 кв. 2025: 2200 = −1 365 000 — малая разность больших слагаемых (2220 = −393 623 601.56); + расхождение 398.44 — округление относительно слагаемых (0,0001 %), а не итога (0,03 %).""" + lines = [ + line("2110", "392258982.50"), + line("2100", "392259000"), + line("2220", "-393623601.56"), + line("2200", "-1365000"), + ] + + checks, _ = run_statement_checks(lines, 1, "INCOME", rules) + + assert checks.relations_failed == [] + assert "2200 = 2100 - 2210 - 2220" in [item.relation for item in checks.soft_relations_failed] diff --git a/api/tests/unit/parser/statements/test_lines.py b/api/tests/unit/parser/statements/test_lines.py index 0a9818e..3e3e74b 100644 --- a/api/tests/unit/parser/statements/test_lines.py +++ b/api/tests/unit/parser/statements/test_lines.py @@ -181,3 +181,20 @@ def test_empty_name_cell_takes_the_nearest_text_to_the_left_of_codes(rules: Rule assert by_code["1100"].line_name == "Итого по разделу I" assert by_code["1100"].line_name_confidence == _FULL_CONFIDENCE assert by_code["1210"].line_name == "" and by_code["1210"].line_name_confidence is None + + +def test_explanation_values_on_continuation_without_header_are_not_names(rules: Rules) -> None: + """N-4: на продолжении без шапки «Пояснения» («5.1») узнаются по коротким значениям и не становятся названием.""" + sheet = sheet_from_rows( + [ + [None, "Запасы", "1210", 5], + ["5.1", "Дебиторская задолженность", "1230", 7], + ["5.2", None, "1200", 12], + ] + ) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) + + names = {line.line_code: line.line_name for line in result.lines} + assert names == {"1210": "Запасы", "1230": "Дебиторская задолженность", "1200": ""} diff --git a/api/tests/unit/parser/statements/test_statement.py b/api/tests/unit/parser/statements/test_statement.py index 7f08660..29be5fd 100644 --- a/api/tests/unit/parser/statements/test_statement.py +++ b/api/tests/unit/parser/statements/test_statement.py @@ -389,3 +389,31 @@ def test_continuation_of_another_form_type_does_not_join(parser: StatementParser assert [document.document_type for document in documents] == ["INCOME", "BALANCE"] assert not ok and CONTINUATION_WARNING in " ".join(warnings) + + +# --- N-1: слабое продолжение (одна кодовая строка) — коды той же формы, присоединение в предупреждениях --- + + +def test_single_code_sheet_join_is_reported(parser: StatementParser) -> None: + single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2") + + _, warnings, _ = parser.parse_sheets( + [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert "sheet=ОФР_2 присоединён к документу как продолжение по 1 кодовой строке" in warnings + + +def test_single_code_sheet_of_another_form_does_not_join(parser: StatementParser) -> None: + """Лист «Справка» с кодом баланса после ОФР — не продолжение ОФР, даже при совпавшем числе колонок.""" + single = sheet_from_rows([["Основные средства", "1150", 0]], "Справка") + + documents, warnings, ok = parser.parse_sheets( + [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert not ok and len(documents) == 1 and "1150" not in [line.line_code for line in documents[0].lines] + assert any( + text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME") + for text in warnings + ) diff --git a/api/tests/unit/parser/test_book_parser.py b/api/tests/unit/parser/test_book_parser.py index c15ccce..ba4dcf0 100644 --- a/api/tests/unit/parser/test_book_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -713,3 +713,54 @@ def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None: assert outcome.status is ResultStatus.NEEDS_CHECK assert len(outcome.documents) == 1 + + +# --- N-2/N-3: лист с одной кодовой строкой — продолжение только сразу после листа формы --- + +BALANCE_ROWS: list[Cells] = [ + ["Бухгалтерский баланс"], + ["на 31 марта 2025 г."], + ["Организация", "ООО Ромашка"], + ["Наименование показателя", "Код", "На 31 марта 2025 г."], + ["Основные средства", "1150", 100], + ["Итого по разделу I", "1100", 100], + ["Запасы", "1210", 50], + ["Итого по разделу II", "1200", 50], + ["БАЛАНС", "1600", 150], +] + + +def test_single_code_sheet_after_osv_sheet_is_not_a_continuation(rules: Rules) -> None: + """Баланс, лист ОСВ, затем лист с одной строкой 1700: между формой и листом стоит ОСВ — лист пропущен.""" + data_ = xlsx({"ББ": BALANCE_ROWS, "Лист_1": mini_osv(), "Хвост": [["БАЛАНС", "1700", 150]]}) + + outcome = book(rules).parse(data_, "XLSX") + + assert "sheet=Хвост skipped: нет признаков ОСВ или формы" in warnings_of(outcome) + statements = [document for document in outcome.documents if not isinstance(document, OsvDocument)] + assert len(statements) == 1 and "1700" not in [line.line_code for line in statements[0].lines] + + +def test_lone_single_code_sheet_is_not_a_form(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"Справка": [["Основные средства", "1150", 100]]}), "XLSX") + + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert outcome.error.code is ErrorCode.INVALID_DOCUMENT + + +def test_single_code_sheet_right_after_form_joins(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "ББ_2": [["БАЛАНС", "1700", 150]]}), "XLSX") + + (document,) = outcome.documents + assert not isinstance(document, OsvDocument) + assert document.locator == "sheet:ББ,ББ_2" and [line.line_code for line in document.lines][-1] == "1700" + + +def test_statement_sheets_are_reported_when_osv_sheet_fails(rules: Rules) -> None: + """T7.1: лист ОСВ сломан — ответ с ошибкой всё равно называет листы форм, которые остались неразобранными.""" + broken: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]] + + outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "ОСВ 60": broken}), "XLSX") + + assert outcome.status is ResultStatus.ERROR + assert "sheet=ББ форма не разобрана: книга завершилась ошибкой листа ОСВ «ОСВ 60»" in warnings_of(outcome) diff --git a/openspec/specs/statement-parsing/spec.md b/openspec/specs/statement-parsing/spec.md index 9950d78..f0c0c2d 100644 --- a/openspec/specs/statement-parsing/spec.md +++ b/openspec/specs/statement-parsing/spec.md @@ -133,10 +133,15 @@ false`; `confidence = 100`. Повтор кода внутри документ Соотношения SHALL храниться в `statement_checks.yaml` в виде `{form, relation, strict, abs_terms}` и вычисляться чистой функцией над «код → сумма» по каждой колонке-периоду отдельно и только по четырёхзначным кодам; отсутствующий код — 0; соотношение SHALL считаться выполненным, если сходится правая часть как записана либо с членами `abs_terms` по -модулю, с допуском ±1 в единицах формы. Нарушение жёсткого соотношения SHALL попадать в `checks.relations_failed` +модулю, с допуском ±1 в единицах формы. Единицы шапки считаются верными (OQ-138, решение владельца 06.10.2026): +расхождение жёсткого соотношения больше ±1, но не больше `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю +числа соотношения SHALL считаться округлением итогов — попадать в `checks.soft_relations_failed` с предупреждением +«сошлось в пределах округления» и не менять статус. Нарушение жёсткого соотношения сверх этого SHALL попадать в `checks.relations_failed` (`column_index`, `relation`, `expected`, `actual`) и переводить документ в `needs_check`; нарушение мягкого — в `checks.soft_relations_failed` и предупреждение. Проверка «сумма пятизначных подстрок = родитель» SHALL генерироваться по -коду как мягкая, когда подстроки есть. Перечень нарушений SHALL дублироваться строками в `metrics.warnings`. +коду как мягкая, когда подстроки есть; пятизначная строка SHALL относиться к ближайшей четырёхзначной строке выше с теми же +первыми тремя цифрами (12310 под 1230), а без такой строки — к строке с её первыми четырьмя цифрами. Перечень нарушений +SHALL дублироваться строками в `metrics.warnings`. #### Scenario: Полные формы сходятся - **WHEN** разбираются обе формы Межениновской (`1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`) @@ -145,8 +150,13 @@ false`; `confidence = 100`. Повтор кода внутри документ #### Scenario: Знаки 1С - **WHEN** в ОФР Межениновской расходы записаны со знаком минус (2120 = −417659 при 2110 = 276851 и 2100 = −140808) - **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms`, в `relations_failed` его нет; то же доказывает - unit-тест `test_relation_holds_with_abs_terms`. Реальные ОФР Унагранде (страница 1) и НПЗ расходятся по 2100 из-за единиц - (рубли с копейками при шапке «тыс. руб.», OQ-138), а не из-за знака + unit-тест `test_relation_holds_with_abs_terms` + +#### Scenario: Округление итогов +- **WHEN** в ОФР Унагранде Компани (страница 1) итог 2100 = 2 871 145 000 при 2110 − 2120 = 2 871 144 583.67, а в ОФР + Унагранде Групп 3 кв. 2025 итог 2200 = −1 365 000 при 2220 = −393 623 601.56 (расхождение 398.44) +- **THEN** нарушения в `soft_relations_failed` с предупреждением «сошлось в пределах округления», `relations_failed = []`, + статус `done`; расхождение 11 при итоге 1 000 000 (больше 0,001 %) — в `relations_failed`, `needs_check` #### Scenario: Испорченный итог - **WHEN** в синтетическом балансе 1600 на 5 больше суммы 1100 + 1200 @@ -164,22 +174,26 @@ false`; `confidence = 100`. Повтор кода внутри документ Документ SHALL считаться `ok`, если все жёсткие соотношения сошлись по всем колонкам, дубликатов кодов нет, колонка кодов найдена, есть хотя бы одна колонка-период, в шапке есть организация или ИНН и документ не является одиночным продолжением. Книга SHALL получать `done`, если все документы ОСВ и форм `ok`, иначе `needs_check`; книга без документов -ОСВ и форм SHALL давать `error INVALID_DOCUMENT`. +ОСВ и форм SHALL давать `error INVALID_DOCUMENT`. `metrics.rows` книги SHALL быть суммой строк данных ОСВ (без `TOTAL`) +и строк с кодом документов форм. Если книга завершилась ошибкой листа ОСВ, ответ с ошибкой SHALL называть в +`metrics.warnings` листы форм, оставшиеся неразобранными. #### Scenario: Баланс на пяти листах - **WHEN** разбирается книга НПЗ - **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"` и строками от 1105 до 1700 включительно, `INCOME` на трёх листах (лист отчёта с единственной строкой 2400 присоединяется - как продолжение: после листа формы хватает одного кода, `min_codes_continuation`), строка 2400 присутствует; `checks.relations_failed` баланса содержит только - `1600 = 1700` по трём колонкам (расхождения 1.94 / 9.02 / 2.48 руб.: значения в рублях с копейками при шапке - «тыс. руб.», OQ-138; допуск ±1 не меняется); `INCOME` — `needs_check` по `2100`, `2200`, `2300` (OQ-138); статус - книги `needs_check` + как продолжение: после листа формы хватает одного кода, `min_codes_continuation`), строка 2400 присутствует; + `checks.relations_failed` пусты у обоих; `1600 = 1700` (1.94 / 9.02 / 2.48) и `2100`/`2200`/`2300` — в + `soft_relations_failed` как округление (OQ-138); статус книги `done` #### Scenario: Лист-продолжение с одной кодовой строкой -- **WHEN** после листа формы идёт лист с единственной кодовой строкой (`2400`, меньше `min_codes`) и тем же числом колонок сумм -- **THEN** лист присоединяется к последнему открытому документу, строка присутствует; если присоединить нельзя (число - колонок или тип формы маркера не совпали), книга `needs_check` и предупреждение «лист с кодами форм (N < min_codes) не - присоединён: …»; первая страница формы с одной кодовой строкой формой не считается +- **WHEN** **сразу** после листа формы идёт лист с единственной кодовой строкой (`2400`, меньше `min_codes`), коды из + диапазона формы открытого документа и то же число колонок сумм +- **THEN** лист присоединяется к последнему открытому документу, строка присутствует, предупреждение «присоединён к + документу как продолжение по N кодовой строке»; если присоединить нельзя (число колонок, тип формы маркера или коды + другой формы — лист «Справка» с кодом баланса после ОФР), книга `needs_check` и предупреждение «лист с кодами форм + (N < min_codes) не присоединён: …»; лист с одной кодовой строкой после листа ОСВ или прочего листа, а также первая + страница формы с одной кодовой строкой формой не считаются (пропуск с предупреждением) #### Scenario: Одинокая страница 2 - **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет) diff --git a/openspec/specs/tabular-recognition-service/spec.md b/openspec/specs/tabular-recognition-service/spec.md index 8cc8a08..f16389d 100644 --- a/openspec/specs/tabular-recognition-service/spec.md +++ b/openspec/specs/tabular-recognition-service/spec.md @@ -106,10 +106,9 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р #### Scenario: Реальные формы через воркер - **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX` -- **THEN** после ack публикуется конверт `needs_check` с `result_schema_version = "1.2"` (книга НПЗ: `1600 = 1700` - расходится на 1.94 / 9.02 / 2.48 руб. — значения в рублях при шапке «тыс. руб.», OQ-138), файл результата содержит два - `statementDocument` (`BALANCE`, `INCOME`); `checks.relations_failed` баланса содержит только `1600 = 1700` по трём - колонкам, у `INCOME` — нарушения `2100`, `2200`, `2300` по двум колонкам (те же рубли при «тыс. руб.», OQ-138) +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата содержит два + `statementDocument` (`BALANCE`, `INCOME`) с пустыми `checks.relations_failed`; расхождения итогов в пределах округления + (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/`2300`) — в `soft_relations_failed` (OQ-138) #### Scenario: Книга без ОСВ и форм - **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы