diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml index 8d1d1da..8eba216 100644 --- a/api/rules/data_types.yaml +++ b/api/rules/data_types.yaml @@ -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] diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index f5dee97..c39bdcc 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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): diff --git a/api/tests/fixtures/test_classify_real.py b/api/tests/fixtures/test_classify_real.py index 85b79fc..632e98f 100644 --- a/api/tests/fixtures/test_classify_real.py +++ b/api/tests/fixtures/test_classify_real.py @@ -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 diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 7ba8974..4936a97 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -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( diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index d7dfd16..765c291 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -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