«Кол-во» — количество, «Вал» без точки — не валюта; таблица кончается на первой «Итого» (#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:
|
standalone_header:
|
||||||
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
|
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
|
||||||
|
|
||||||
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
|
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка:
|
||||||
|
# так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663).
|
||||||
|
# «Кол-во»: нормализация текста превращает дефис в пробел
|
||||||
row:
|
row:
|
||||||
- ['^вал\.?$', CURRENCY]
|
- ['^вал\.$', CURRENCY]
|
||||||
- ['^кол(ичество)?\.?$', QUANTITY]
|
- ['^кол(ичество|\s?во)?\.?$', QUANTITY]
|
||||||
|
|
||||||
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
|
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
|
||||||
currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]
|
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:
|
def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None:
|
||||||
"""Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
|
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
|
||||||
|
|
||||||
Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной
|
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
|
||||||
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
|
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Суммы не требуются (R-13: пустая ОСВ —
|
||||||
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
|
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
|
||||||
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
|
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
|
||||||
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого»
|
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
|
||||||
перед ней; без обычной «Итого» вторая строка служит единственной итоговой.
|
TOTAL.
|
||||||
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
|
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
|
||||||
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
|
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
|
||||||
|
единственной итоговой.
|
||||||
|
Известный риск: на листе без outline и отступов контрагент «Итого-…» на корневом уровне становится TOTAL; это ловит
|
||||||
|
проверка «TOTAL = корень» (§1.4).
|
||||||
"""
|
"""
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
outline_floor = min(sheet.outline(row) for row in rows)
|
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)
|
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
|
||||||
only_secondary: int | None = None
|
only_secondary: int | None = None
|
||||||
for row in reversed(rows):
|
for row in rows:
|
||||||
text = sheet.text_at(row, header.text_col)
|
text = sheet.text_at(row, header.text_col)
|
||||||
if not any(pattern.search(text) for pattern in rules.headers.total_row):
|
if not any(pattern.search(text) for pattern in rules.headers.total_row):
|
||||||
continue
|
continue
|
||||||
_, indent = _text_cell_style(sheet, row, header.text_col)
|
_, indent = _text_cell_style(sheet, row, header.text_col)
|
||||||
if sheet.outline(row) != outline_floor or indent != indent_floor:
|
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
|
continue
|
||||||
return row
|
if not _is_secondary_total(text, rules):
|
||||||
|
return row
|
||||||
|
only_secondary = only_secondary or row
|
||||||
return only_secondary
|
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:
|
def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool:
|
||||||
"""Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные."""
|
"""Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные."""
|
||||||
if len(rows) < MIN_ROWS_WITH_TOTAL:
|
if len(rows) < MIN_ROWS_WITH_TOTAL:
|
||||||
@ -268,7 +280,11 @@ def collect_body_rows(
|
|||||||
f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)"
|
f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)"
|
||||||
)
|
)
|
||||||
if total_row is not None:
|
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)
|
label_cols = _label_cols(sheet, header, rows)
|
||||||
body: list[BodyRow] = []
|
body: list[BodyRow] = []
|
||||||
for index, row in enumerate(rows, start=1):
|
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]
|
total = totals[0]
|
||||||
assert total is parsed.rows[-1]
|
assert total is parsed.rows[-1]
|
||||||
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
|
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
|
@pytest.mark.fixtures
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import pytest
|
|||||||
from gridbuilder import C, L, M, sheet_from_rows
|
from gridbuilder import C, L, M, sheet_from_rows
|
||||||
|
|
||||||
from src.contract.common import DataType
|
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.deadline import Deadline
|
||||||
from src.parser.detect import find_table_header
|
from src.parser.detect import find_table_header
|
||||||
from src.parser.errors import ParseTimeoutError
|
from src.parser.errors import ParseTimeoutError
|
||||||
@ -28,6 +28,7 @@ TURNOVER = "Обороты за период"
|
|||||||
CLOSING = "Сальдо на конец периода"
|
CLOSING = "Сальдо на конец периода"
|
||||||
TIMEOUT_SECONDS = 60
|
TIMEOUT_SECONDS = 60
|
||||||
EXPIRED_AFTER_SECONDS = 1000
|
EXPIRED_AFTER_SECONDS = 1000
|
||||||
|
TAIL_NUMBERS_MARK = "ниже таблицы"
|
||||||
ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
|
ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
|
||||||
DUPLICATED_EXTRA_COLUMNS = 2
|
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")
|
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:
|
def test_doc_data_type_none_passes_through(rules: Rules) -> None:
|
||||||
parsed = parse(_basic(), rules, doc_data_type=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
|
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:
|
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
|
||||||
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
|
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
|
||||||
sheet = sheet_from_rows(
|
sheet = sheet_from_rows(
|
||||||
|
|||||||
@ -571,13 +571,18 @@ def test_header_data_type_unknown(rules: Rules) -> None:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("text", "expected"),
|
("text", "expected"),
|
||||||
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
|
[
|
||||||
|
("Вал.", "CURRENCY"),
|
||||||
|
("Кол.", "QUANTITY"),
|
||||||
|
("Количество", "QUANTITY"),
|
||||||
|
("Кол во", "QUANTITY"), # «Кол-во» после нормализации текста (дефис -> пробел)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
|
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
|
||||||
assert first_match(rules.data_types.row, text) == expected
|
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:
|
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
|
assert first_match(rules.data_types.row, text) is None
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user