ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)

Лист без названия ведомости, не опознанный как форма, — ОСВ, если в первых 30 строках полный заголовок:
три группы, у каждой Дт и Кт рядом. Проверяется раньше слабого продолжения формы (R-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 20:48:46 +04:00
parent f5bb178899
commit b5f0b9d773
8 changed files with 196 additions and 20 deletions

View File

@ -30,6 +30,7 @@ groups:
- 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+начало)' # «Остаток по дебету на начало» (КТЗ, ЧАЗ)
- 'сальдо\s+начальное' # «Сальдо НачальноеДТВВ» (SAP, НМЖК)
- 'сальдо\s+вх\b' # «Сальдо вх Дт» (SAP, Виола)
- '^начало(?=\s*(дт|кт)\b)' # «НачалоДт» (Navision без названия ведомости, Айсберри)
turnover:
- '\bоборот(ы)?\b'
- '^оборот(ы)?(?=\s*(дт|кт))' # «ОборотДт» (Navision), «ОборотыДтВВ» (SAP, НМЖК)
@ -41,6 +42,7 @@ groups:
- 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+конец)' # «Остаток по кредиту на конец» (КТЗ, ЧАЗ)
- 'сальдо\s+конечное' # «Сальдо КонечноеКТВВ» (SAP, НМЖК)
- 'сальдо\s+исх\b' # «Сальдо исх Кт» (SAP, Виола)
- '^конец(?=\s*(дт|кт)\b)' # «КонецКт» (Navision без названия ведомости, Айсберри)
# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т») или сторона в остатке ячейки группы:
# ячейка (остаток) начинается с синонима

View File

@ -35,7 +35,7 @@ from src.contract import (
)
from src.parser import ParseOutcome
from src.parser.deadline import Deadline
from src.parser.detect import is_osv_sheet
from src.parser.detect import has_osv_table, is_osv_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.osv import OsvParser, data_rows
from src.parser.osv_warnings import OSV_XLSB_NO_OUTLINE, OsvNote
@ -143,7 +143,13 @@ class BookParser:
if read.no_codepage:
warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules)))
sheets = read.sheets
plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline)
plan = plan_sheets(
sheets,
lambda sheet: is_osv_sheet(sheet, self._rules),
rules,
deadline,
osv_by_table=lambda sheet: has_osv_table(sheet, self._rules),
)
osv_names = {sheet.name for sheet in plan.osv}
other_names = {sheet.name for sheet in plan.other}
reader_notes = _reader_notes(read)

View File

@ -30,6 +30,7 @@ __all__ = [
"classify_sheet",
"find_table_header",
"first_data_row",
"has_osv_table",
"inn_from_cell",
"is_header_repeat",
"is_osv_sheet",
@ -166,13 +167,35 @@ def is_osv_sheet(sheet: Sheet, rules: Rules) -> bool:
def classify_sheet(sheet: Sheet, rules: Rules, statement_rules: StatementRules | None = None) -> SheetKind:
"""ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по словарям
пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию)."""
пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию); «прочее» с полным заголовком
таблицы ОСВ — ОСВ (`has_osv_table`)."""
if is_osv_sheet(sheet, rules):
return SheetKind.OSV
# цикл импорта: forms → cells → number_format → numbers → detect (INN_LENGTHS, inn_from_cell)
from src.parser.statements.forms import classify_statement_sheet # noqa: PLC0415
return _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())]
kind = _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())]
if kind is SheetKind.OTHER and has_osv_table(sheet, rules):
return SheetKind.OSV
return kind
def has_osv_table(sheet: Sheet, rules: Rules) -> bool:
"""Запасной признак листа ОСВ без названия ведомости (#81, design D1): в первых `SHEET_MARKER_ROWS` строках есть
строка со всеми тремя группами сумм, и у каждой группы — колонки «Дебет» и «Кредит» (свёрнутое сальдо не годится,
R-3), между которыми в строках заголовка нет других подписей (R-11: у отчёта 1С «Обороты счета» между «Оборот Дт» и
«Оборот Кт» — колонки корр. счетов, это не ОСВ). Колонки ищутся тем же разбором, что у обычного заголовка (R-6)."""
for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1):
cells = _group_cells(sheet, row, rules)
if {cell.group for cell in cells} != set(GROUP_ORDER):
continue
try:
groups = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER]
except InvalidFormatError:
continue
if all(_sides_side_by_side(sheet, row, columns) for columns in groups):
return True
return False
# --- Заголовок таблицы ---
@ -311,6 +334,18 @@ def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit:
return _GroupColumns(debit, credit, None)
def _sides_side_by_side(sheet: Sheet, group_row: int, columns: _GroupColumns) -> bool:
"""У группы найдены «Дебет» и «Кредит», и между их колонками в строках заголовка (от строки групп до нижней строки
подзаголовков) нет других непустых ячеек."""
if columns.debit is None or columns.credit is None:
return False
left, right = sorted((columns.debit, columns.credit), key=lambda cell: cell.col)
bottom = max(left.bottom, right.bottom)
return not any(
left.right < col < right.col for row in range(group_row, bottom + 1) for col, _ in _texts(sheet, row)
)
def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool:
"""Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто.

View File

@ -54,10 +54,16 @@ class SheetPlan:
def plan_sheets(
sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline
sheets: Sequence[Sheet],
is_osv: Callable[[Sheet], bool],
rules: StatementRules,
deadline: Deadline,
osv_by_table: Callable[[Sheet], bool] | None = None,
) -> SheetPlan:
"""Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа
формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после»."""
"""Листы по порядку книги: ОСВ; форма; продолжение; ОСВ по заголовку таблицы (`osv_by_table`, запасной признак
#81: только для листа, который не форма и не продолжение по признакам пакета, — раньше слабого продолжения, R-12);
слабое продолжение — лист из 1–2 кодов сразу после листа формы или продолжения; прочее. Лист ОСВ или «прочее»
прерывает «сразу после»."""
osv: list[Sheet] = []
statements: list[tuple[Sheet, SheetKind]] = []
other: list[Sheet] = []
@ -75,6 +81,9 @@ def plan_sheets(
last_type = blocks[-1].type_hint if blocks else None
elif kind == "continuation":
statements.append((sheet, SheetKind.STATEMENT_CONTINUATION))
elif osv_by_table is not None and osv_by_table(sheet):
osv.append(sheet)
last_type = None
elif last_type is not None and is_weak_continuation(sheet, rules, last_type):
statements.append((sheet, SheetKind.STATEMENT_CONTINUATION))
else:

View File

@ -68,8 +68,6 @@ S55 = "s55_not_osv_titles.xlsx"
S56 = "s56_almost_osv_tables.xlsx"
S57 = "s57_form_mentions_osv.xlsx"
OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57)
PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы"
PENDING_81 = {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK}
S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка")
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
@ -312,10 +310,8 @@ def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
def test_header_status_and_hierarchy(synthetic_file: Path, request: pytest.FixtureRequest) -> None:
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
if synthetic_file.name in PENDING_81:
request.applymarker(pytest.mark.xfail(strict=True, reason=PENDING_81[synthetic_file.name]))
expect = EXPECTED[synthetic_file.name]
outcome = parse_path(synthetic_file)
@ -900,7 +896,6 @@ def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None:
assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY"
@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK)
def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None:
"""Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак)."""
outcome = parse_named(fixtures_dir, S51)
@ -911,7 +906,6 @@ def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None:
assert_done_with_sums(doc, ALFA, ALFA_SUMS)
@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK)
def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None:
"""Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ,
«Справка» — прочий лист."""
@ -934,7 +928,6 @@ def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None:
assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS)
@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK)
def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None:
"""s51 в xlsb: ОСВ по запасному признаку, те же суммы."""
outcome = parse_named(fixtures_dir, S54)

View File

@ -146,6 +146,61 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) ->
assert [s.name for s in plan.other] == ["Примечания", "2400"]
class _CountingTable:
"""`osv_by_table` для `plan_sheets`: лист с именем из `osv` — ОСВ по таблице; запоминает, для каких листов звали."""
def __init__(self, *osv: str) -> None:
self.osv = set(osv)
self.asked: list[str] = []
def __call__(self, sheet: Sheet) -> bool:
self.asked.append(sheet.name)
return sheet.name in self.osv
def _no_osv(sheet: Sheet) -> bool:
return False
def test_plan_sheets_table_fallback_only_for_other(rules: StatementRules) -> None:
"""#81, D1: запасной признак не спрашивается у листа формы; лист с таблицей ОСВ — ОСВ, «Справка» — прочее."""
form = _form("Бухгалтерский баланс", BALANCE_CODES, "ББ")
table = sheet_from_rows([["НачалоДт"]], name="ОВ")
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
by_table = _CountingTable("ОВ")
plan = plan_sheets([form, table, reference], _no_osv, rules, NO_DEADLINE, osv_by_table=by_table)
assert plan.osv == (table,)
assert [(s.name, k) for s, k in plan.statements] == [("ББ", STATEMENT)]
assert plan.other == (reference,)
assert by_table.asked == ["ОВ", "Справка"]
def test_plan_sheets_table_fallback_before_weak_continuation(rules: StatementRules) -> None:
"""R-12: лист с таблицей ОСВ после формы — ОСВ, а не слабое продолжение; без таблицы — продолжение, как было
(Review Focus 4)."""
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400")
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable())
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
assert plan.osv == ()
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable("2400"))
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT)]
assert plan.osv == (weak,)
def test_plan_sheets_without_table_fallback_is_unchanged(rules: StatementRules) -> None:
"""Без `osv_by_table` — прежнее поведение: «прочее» остаётся прочим."""
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
plan = plan_sheets([reference], _no_osv, rules, NO_DEADLINE)
assert (plan.osv, plan.statements, plan.other) == ((), (), (reference,))
def test_is_osv_sheet_by_marker() -> None:
rules = load_rules()
osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]])

View File

@ -17,6 +17,7 @@ from src.parser.detect import (
classify_sheet,
find_table_header,
first_data_row,
has_osv_table,
inn_from_cell,
is_header_repeat,
)
@ -110,6 +111,69 @@ def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None:
assert classify_sheet(sheet, rules) is SheetKind.OTHER
# --- Запасной признак листа ОСВ по заголовку таблицы (#81, design D1, R-2, R-3, R-11) ---
NAVISION_HEADER = ["ФинСчет", "ИсточникНазвание", "НачалоДт", "НачалоКт", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"]
NAVISION_ROW = at({1: "60", 3: 10, 5: 5, 7: 15})
def test_has_osv_table_navision_split_sides(rules: Rules) -> None:
"""Navision без названия: все три группы, у каждой Дт и Кт в ячейках группы — ОСВ."""
assert has_osv_table(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is True
def test_has_osv_table_with_subheaders(rules: Rules) -> None:
"""Обычный заголовок: группы над «Дебет | Кредит»."""
sheet = sheet_from_rows(
[
["Счет", OPENING, None, TURNOVER, None, CLOSING],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
at({1: "60", 2: 10, 7: 10}),
]
)
assert has_osv_table(sheet, rules) is True
def test_has_osv_table_requires_opening(rules: Rules) -> None:
header = ["Счет", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 5})]), rules) is False
def test_has_osv_table_rejects_collapsed(rules: Rules) -> None:
header = ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 3: 10})]), rules) is False
def test_has_osv_table_rejects_disclosure(rules: Rules) -> None:
"""Расшифровка с остатками: «Поступило/Выбыло» — не обороты (Review Focus 1)."""
header = ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"]
assert has_osv_table(sheet_from_rows([header, ["Материалы", 100, 50, 30, 120]]), rules) is False
def test_has_osv_table_rejects_columns_between_sides(rules: Rules) -> None:
"""1С «Обороты счета»: между «Оборот Дт» и «Оборот Кт» — колонки корр. счетов (R-1, R-11)."""
header = ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"]
header += ["Конечное сальдо Дт", "Конечное сальдо Кт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 4: 50})]), rules) is False
@pytest.mark.parametrize(("blank_rows", "expected"), [(29, True), (30, False)])
def test_has_osv_table_only_first_rows(rules: Rules, blank_rows: int, expected: bool) -> None:
"""Строка групп ищется в первых `SHEET_MARKER_ROWS` (30) строках (R-2): 30-я — да, 31-я — нет."""
sheet = sheet_from_rows([*([[]] * blank_rows), NAVISION_HEADER, NAVISION_ROW])
assert has_osv_table(sheet, rules) is expected
def test_classify_sheet_falls_back_to_table(rules: Rules) -> None:
assert classify_sheet(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is SheetKind.OSV
# --- find_table_header: варианты заголовка ---

View File

@ -6,11 +6,13 @@
дефисов). Признаки ОСВ — названия ведомости разных учётных систем («Оборотно-сальдовая ведомость», «Оборотная
ведомость», «ОСВ по сч.», «Оборотный баланс», «Фин. Обороты», «Отчет по Главной книге»). Лист с признаками формы ББ/ОФР
или её продолжения уходит в конвейер форм (спека `statement-parsing`). Лист, не опознанный ни как ОСВ по признаку, ни
как форма или её продолжение, SHALL считаться листом ОСВ по запасному признаку, если в первых `SHEET_MARKER_ROWS`
строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец») и у каждой группы найдены обе
стороны — «Дебет» и «Кредит»; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак
MUST проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или
расшифровки. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`,
как форма или её продолжение по признакам форм, SHALL считаться листом ОСВ по запасному признаку, если в первых
`SHEET_MARKER_ROWS` строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец»), у каждой
группы найдены обе стороны — «Дебет» и «Кредит», и между колонками «Дебет» и «Кредит» группы в строках заголовка нет
других подписей; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак MUST
проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или
расшифровки; слабое продолжение (лист из 1–2 кодов сразу после формы) признаком формы не считается — лист с полной
таблицей ОСВ после формы — ОСВ. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`,
`locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`,
`error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с
`locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска.
@ -44,6 +46,16 @@ MUST проверяться только после классификации
- **THEN** вид листа расшифровки тот же, что без запасного признака (форма или продолжение конвейера форм либо
`SHEET_OTHER`), документа ОСВ из него нет
#### Scenario: Лист ОСВ без названия после формы — не слабое продолжение
- **WHEN** разбирается книга: форма ББ, за ней лист Navision без названия ведомости с полным заголовком таблицы, за ним
посторонний лист
- **THEN** форма — свой документ, лист Navision — `osvDocument`, посторонний лист — `SHEET_OTHER`
#### Scenario: Отчёт «Обороты счета» не ОСВ
- **WHEN** лист без названия ведомости содержит все три группы со сторонами, но между «Оборот Дт» и «Оборот Кт» стоят
колонки корреспондирующих счетов
- **THEN** лист пропускается с `SHEET_OTHER`
#### Scenario: Неполная таблица не даёт запасного признака
- **WHEN** лист без названия ведомости содержит группы «обороты» и «конец», но без группы «начало» или со свёрнутым
сальдо (одна колонка в группе)