Merge pull request 'fix(statements): округление итогов мягкое (OQ-138), слабые продолжения, подстроки по порядку (#20)' (#21) from fix/20-continuation-tolerance into main
Reviewed-on: #21
This commit is contained in:
commit
8be2cf1096
@ -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": [
|
||||
{
|
||||
|
||||
@ -44,8 +44,7 @@
|
||||
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true }
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, не код) |
|
||||
|
||||
## Как писать паттерны
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 содержат только четырёхзначные коды; пятизначные — только в подстроках."""
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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": ""}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 в пакете нет)
|
||||
|
||||
@ -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, ни один лист которой не имеет признаков ОСВ или формы
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user