«Кол-во» — количество, «Вал» без точки — не валюта; таблица кончается на первой «Итого» (#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:
parent
591b3f4434
commit
8b91bcae3d
@ -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]
|
||||
|
||||
@ -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):
|
||||
|
||||
3
api/tests/fixtures/test_classify_real.py
vendored
3
api/tests/fixtures/test_classify_real.py
vendored
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user