fix(parser): буквенные субсчета 76.АВ/ВА/ОТ — счета (T8.1, R-16); тесты T8.2, T8.3, T8.5

ACCOUNT_RE: сегмент субсчёта — 1–2 цифры или 1–3 прописные буквы кириллицы/латиницы
(без IGNORECASE); ранг — число сегментов («76.АВ» — сосед «76.04»). Остаточный
ложноположительный «12.ООО» описан в docstring.
Тесты: буквенные субсчета и их дети (outline и account_pattern), отказы «76.АВГД»/«76.ав»;
допуск «60,Расчеты» назван намеренным (R-18); fixtures-свойство «нет строк за схемой»,
data_type строк = data_type шапки (T8.3); негатив R-13: «Итого» без закрепления
вкладывается под последний счёт (T8.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 14:19:22 +04:00
parent e1dc3ba075
commit da7215b314
4 changed files with 103 additions and 11 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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: