«Кол-во» — количество, «Вал» без точки — не валюта; таблица кончается на первой «Итого» (#47, AURORA-1663, AURORA-1664)

AURORA-1663: словарь строк data_types.yaml — «Кол-во» даёт QUANTITY, «Вал» без
точки больше не CURRENCY (материал «Вал» на счёте 10); метка — «Вал.» и код
валюты.

AURORA-1664: _find_total берёт первую «Итого» на корневом уровне оформления
(раньше — последнюю), вторая итоговая строка сразу за ней по-прежнему часть
таблицы; всё ниже вне документа. Числа в хвосте дают предупреждение «ниже
таблицы (после «Итого») есть числа» без смены статуса. Закрывает tp#7 M-2.
Предупреждение появляется и у реальной фикстуры rb/2024-Q1 (контрольные суммы
правее таблицы под «Итого») — тест property допускает его.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 11:50:39 +04:00
parent 591b3f4434
commit 8b91bcae3d
5 changed files with 113 additions and 22 deletions

View File

@ -12,10 +12,12 @@ header:
standalone_header:
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка:
# так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663).
# «Кол-во»: нормализация текста превращает дефис в пробел
row:
- ['^вал\.?$', CURRENCY]
- ['^кол(ичество)?\.?$', QUANTITY]
- ['^вал\.$', CURRENCY]
- ['^кол(ичество|\s?во)?\.?$', QUANTITY]
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]

View File

@ -115,36 +115,48 @@ def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]:
def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None:
"""Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого»
перед ней; без обычной «Итого» вторая строка служит единственной итоговой.
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Суммы не требуются (R-13: пустая ОСВ —
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
TOTAL.
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
единственной итоговой.
Известный риск: на листе без outline и отступов контрагент «Итого-…» на корневом уровне становится TOTAL; это ловит
проверка «TOTAL = корень» (§1.4).
"""
if not rows:
return None
outline_floor = min(sheet.outline(row) for row in rows)
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
only_secondary: int | None = None
for row in reversed(rows):
for row in rows:
text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in rules.headers.total_row):
continue
_, indent = _text_cell_style(sheet, row, header.text_col)
if sheet.outline(row) != outline_floor or indent != indent_floor:
return only_secondary
if _is_secondary_total(text, rules):
only_secondary = only_secondary or row
continue
return row
if not _is_secondary_total(text, rules):
return row
only_secondary = only_secondary or row
return only_secondary
def _numbers_below(sheet: Sheet, last_row: int) -> list[int]:
"""Строки ниже `last_row` (по всему листу, в любых колонках) с числовыми ячейками: хвост вне документа."""
return sorted({row for (row, _), cell in sheet.cells.items() if row > last_row and cell.kind == "number"})
def _tail_warning(rows: Sequence[int]) -> str:
where = f"строка {rows[0]}" if len(rows) == 1 else f"строки {rows[0]}–{rows[-1]}"
return f"{where}: ниже таблицы (после «Итого») есть числа — строки ниже «Итого» вне документа и не разобраны"
def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool:
"""Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные."""
if len(rows) < MIN_ROWS_WITH_TOTAL:
@ -268,7 +280,11 @@ def collect_body_rows(
f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)"
)
if total_row is not None:
rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1]
last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules))
tail = _numbers_below(sheet, rows[last])
if tail:
warnings.append(_tail_warning(tail))
rows = rows[: last + 1]
label_cols = _label_cols(sheet, header, rows)
body: list[BodyRow] = []
for index, row in enumerate(rows, start=1):

View File

@ -25,7 +25,8 @@ def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None
total = totals[0]
assert total is parsed.rows[-1]
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
assert parsed.warnings == []
# Допустимо одно предупреждение: числа в хвосте под «Итого» (контрольные суммы правее таблицы, AURORA-1664)
assert [warning for warning in parsed.warnings if "ниже таблицы" not in warning] == []
@pytest.mark.fixtures

View File

@ -12,7 +12,7 @@ import pytest
from gridbuilder import C, L, M, sheet_from_rows
from src.contract.common import DataType
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows, row_data_type
from src.parser.deadline import Deadline
from src.parser.detect import find_table_header
from src.parser.errors import ParseTimeoutError
@ -28,6 +28,7 @@ TURNOVER = "Обороты за период"
CLOSING = "Сальдо на конец периода"
TIMEOUT_SECONDS = 60
EXPIRED_AFTER_SECONDS = 1000
TAIL_NUMBERS_MARK = "ниже таблицы"
ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
DUPLICATED_EXTRA_COLUMNS = 2
@ -411,6 +412,23 @@ def test_currency_and_quantity_rows(rules: Rules) -> None:
assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60")
@pytest.mark.parametrize(
("text", "expected"),
[
("Кол.", "QUANTITY"),
("Количество", "QUANTITY"),
("Кол-во", "QUANTITY"), # AURORA-1663, O-5
("кол-во.", "QUANTITY"),
("Вал.", "CURRENCY"),
("USD", "CURRENCY"),
("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты
("Вал стальной", None),
],
)
def test_row_data_type_labels(rules: Rules, text: str, expected: str | None) -> None:
assert row_data_type(text, rules, frozenset(rules.data_types.currency_codes)) == expected
def test_doc_data_type_none_passes_through(rules: Rules) -> None:
parsed = parse(_basic(), rules, doc_data_type=None)
@ -478,6 +496,55 @@ def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None:
assert parsed.total_row == parsed.one("Итого").body.row
def test_table_ends_at_first_total_everything_below_is_outside(rules: Rules) -> None:
"""AURORA-1664 (O-12): под таблицей дописаны подпись и блок аналитика, в нём есть и строка «Итого …»: таблица
кончается на первой «Итого», хвост вне документа — ни строкой, ни TOTAL; числа в хвосте — предупреждение.
"""
sheet = osv(
r(0, "60", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого", S100),
[],
["Ответственный:", None, "Иванова"],
r(None, "ООО Альфа (ГК)", (7, None, 5, None, 12, None)),
r(None, "Итого по ГК", (7, None, 5, None, 12, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
assert parsed.total_row == parsed.one("Итого").body.row
assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_text_only_tail_below_total_gives_no_warning(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
["Ответственный:", None, "Иванова"],
)
parsed = parse(sheet, rules)
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_second_total_then_tail_numbers(rules: Rules) -> None:
"""«Итого развернутое» сразу за «Итого» — ещё таблица; числа ниже неё — хвост."""
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
r(None, "Итого развернутое", S100),
r(None, "Итого", (1, None, None, None, 1, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого", "Итого развернутое"]
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings)
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
sheet = sheet_from_rows(

View File

@ -571,13 +571,18 @@ def test_header_data_type_unknown(rules: Rules) -> None:
@pytest.mark.parametrize(
("text", "expected"),
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
[
("Вал.", "CURRENCY"),
("Кол.", "QUANTITY"),
("Количество", "QUANTITY"),
("Кол во", "QUANTITY"), # «Кол-во» после нормализации текста (дефис -> пробел)
],
)
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.row, text) == expected
@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
@pytest.mark.parametrize("text", ["Вал", "Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
assert first_match(rules.data_types.row, text) is None