diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index e6cf810..4a66d93 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -10,7 +10,8 @@ нераспознанные колонки таблицы (`extra`). Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде — -`ACCOUNT_RE`: формат кода счёта — структура, а не синоним. +`ACCOUNT_RE`: формат кода счёта — структура, а не синоним. Буквенные сегменты субсчёта (R-16) оставляют известный +остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт. """ import re @@ -38,9 +39,11 @@ __all__ = [ "collect_body_rows", ] -# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»), после запятой — название -# («60.01, Расчеты с поставщиками»). Группа 1 — код как в файле (`account_code_raw`) -ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*(.*))?$") +# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или +# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается: +# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен, +# R-18). Группа 1 — код как в файле (`account_code_raw`) +ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$") _SEGMENT_SEPARATOR = "." # Кооперативный дедлайн (design D-8): проверка каждые N строк листа DEADLINE_CHECK_EVERY_ROWS = 500 diff --git a/api/tests/fixtures/real_osv.py b/api/tests/fixtures/real_osv.py index 228feb4..9bbbdd7 100644 --- a/api/tests/fixtures/real_osv.py +++ b/api/tests/fixtures/real_osv.py @@ -17,7 +17,7 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows from src.parser.deadline import Deadline from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header from src.parser.grid import Sheet -from src.parser.header import build_group_schema, parse_doc_header +from src.parser.header import DocHeader, build_group_schema, parse_doc_header from src.parser.hierarchy import BodyRow, Levels, resolve_levels from src.parser.readers import read_book from src.parser.rules import load_rules @@ -29,6 +29,7 @@ PARSE_TIMEOUT_SECONDS = 120 class RealParse: sheet: Sheet header: TableHeader + doc: DocHeader schema: list[OsvGroup] body: list[BodyRow] total_row: int | None @@ -65,4 +66,4 @@ def parse_real(path: Path) -> RealParse: levels = resolve_levels(body, total_row, deadline) rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules) seconds = time.monotonic() - started - return RealParse(sheet, header, schema, body, total_row, levels, rows, warnings, seconds) + return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds) diff --git a/api/tests/fixtures/test_classify_real.py b/api/tests/fixtures/test_classify_real.py index 4978786..85b79fc 100644 --- a/api/tests/fixtures/test_classify_real.py +++ b/api/tests/fixtures/test_classify_real.py @@ -32,10 +32,11 @@ def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None def test_real_osv_rows_are_classified_into_schema(osv_file: Path) -> None: parsed = parse_real(osv_file) - schema_types = {group.type for group in parsed.schema} for row in parsed.data_rows: - assert row.group_type == "OTHER" or row.group_type in schema_types, row - assert row.data_type == "BU" # в фикстурах нет строк «Вал.»/«Кол.» + assert row.data_type == parsed.doc.data_type, row # в фикстурах нет строк «Вал.»/«Кол.» + # Ни одной строки за схемой группировок: OTHER без имени группы допустим только у строк-показателей + if row.data_type not in ("CURRENCY", "QUANTITY"): + assert not (row.group_type == "OTHER" and row.group_name is None), row if row.counterparty_inn is not None: assert row.counterparty_inn.isdigit() assert len(row.counterparty_inn) in INN_LENGTHS diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index e7f3712..72aeaaf 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -116,7 +116,14 @@ def _basic() -> Sheet: ("001", "001"), ("60.1", "60.1"), ("60.01, Расчеты с поставщиками", "60.01"), - ("60,Расчеты", "60"), + # R-16: буквенные субсчета плана счетов 1С (НДС по авансам и т. п.) + ("76.АВ", "76.АВ"), + ("76.ВА", "76.ВА"), + ("76.ОТ", "76.ОТ"), + ("76.АВ, НДС по авансам и предоплатам", "76.АВ"), + ("76.АВ.1", "76.АВ.1"), + ("19.ЁЖ", "19.ЁЖ"), + ("60.AB", "60.AB"), ], ) def test_account_re_matches_account_codes(text: str, code: str) -> None: @@ -126,13 +133,88 @@ def test_account_re_matches_account_codes(text: str, code: str) -> None: assert found.group(1) == code +def test_account_re_tolerates_no_space_after_comma() -> None: + """R-18: намеренный допуск шире текста спеки (там после запятой обязателен пробел): «60,Расчеты» — счёт 60.""" + found = ACCOUNT_RE.match("60,Расчеты") + + assert found is not None + assert found.group(1) == "60" + + @pytest.mark.parametrize( - "text", ["6", "6001", "60.001", "60.01.1.1", "ООО 60", "60 лет Октября", "Итого", "12.2025 г."] + "text", + [ + "6", + "6001", + "60.001", + "60.01.1.1", + "ООО 60", + "60 лет Октября", + "Итого", + "12.2025 г.", + "76.АВГД", # R-16: буквенный сегмент — не длиннее трёх букв + "76.ав", # только прописные: без IGNORECASE + "76.Ав", + "76.А1", + ], ) def test_account_re_rejects_non_accounts(text: str) -> None: assert ACCOUNT_RE.match(text) is None +def test_account_re_residual_false_positive_documented() -> None: + """Известный остаточный ложноположительный случай R-16: «12.ООО» (две цифры, точка, до трёх прописных букв) + принимается за счёт; описан в docstring модуля `classify`. + """ + assert ACCOUNT_RE.match("12.ООО") is not None + + +def test_letter_subaccount_is_account_with_children(rules: Rules) -> None: + """R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор.""" + sheet = osv( + r(0, "76", S100), + r(1, "76.04", S100), + r(2, "ООО Первый", S100, inn="7701234567"), + r(1, "76.АВ, НДС по авансам", S100), # 10 + r(2, "Русский бисквит", S100, inn="3528052718"), # 11 + r(3, "Договор № 5", S100), + r(None, "Итого", S100), + ) + + parsed = parse(sheet, rules) + + letter = parsed.one("76.АВ, НДС по авансам") + assert (letter.group_type, letter.account_code_raw, letter.body.account_rank) == ("ACCOUNT", "76.АВ", 2) + counterparty = parsed.one("Русский бисквит") + assert (counterparty.group_type, counterparty.account_code_raw, counterparty.parent) == ( + "COUNTERPARTY", + "76.АВ", + letter.body.row, + ) + assert counterparty.counterparty_inn == "3528052718" + assert parsed.one("Договор № 5").group_type == "CONTRACT" + + +def test_letter_subaccount_is_sibling_in_account_pattern(rules: Rules) -> None: + """R-16: без outline и отступов (стратегия account_pattern) «76.АВ» — сосед «76.04», а не его ребёнок.""" + sheet = osv( + r(None, "76", S100), # 7 + r(None, "76.04", S100), # 8 + r(None, "ООО Первый", S100), # 9 + r(None, "76.АВ", S100), # 10 + r(None, "Русский бисквит", S100), # 11 + r(None, "Итого", S100), + ) + + parsed = parse(sheet, rules) + + assert parsed.levels.strategy == "account_pattern" + sub_04, letter = parsed.one("76.04"), parsed.one("76.АВ") + assert (letter.level, letter.parent) == (sub_04.level, sub_04.parent) == (1, ACCOUNT_ROW) + assert parsed.one("Русский бисквит").parent == letter.body.row + assert parsed.one("Русский бисквит").group_type == "COUNTERPARTY" + + # --- Группы по позиции в схеме --- @@ -446,6 +528,11 @@ def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) -> assert parsed.levels.strategy == "account_pattern" total = parsed.one("Итого") assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None) + # Негатив (T8.5): то же «Итого», не переданное в каскад, account_pattern вложил бы под последний счёт + header = find_table_header(sheet, rules) + body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) + unpinned = resolve_levels(body, None, Deadline(TIMEOUT_SECONDS)) + assert unpinned.by_row[total.body.row] > 0 def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None: