Merge pull request 'fix(statements): округление итогов мягкое (OQ-138), слабые продолжения, подстроки по порядку (#20)' (#21) from fix/20-continuation-tolerance into main

Reviewed-on: #21
This commit is contained in:
aep 2026-10-06 22:21:59 +03:00
commit 8be2cf1096
18 changed files with 326 additions and 60 deletions

View File

@ -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": [
{

View File

@ -44,8 +44,7 @@
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true }
]
}
],
"checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] }
]
}
]
}

View File

@ -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": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" }
}
}

View File

@ -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, не код) |
## Как писать паттерны

View File

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

View File

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

View File

@ -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 содержат только четырёхзначные коды; пятизначные — только в подстроках."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 в пакете нет)

View File

@ -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, ни один лист которой не имеет признаков ОСВ или формы