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:
parent
e1dc3ba075
commit
da7215b314
@ -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
|
||||
|
||||
5
api/tests/fixtures/real_osv.py
vendored
5
api/tests/fixtures/real_osv.py
vendored
@ -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)
|
||||
|
||||
7
api/tests/fixtures/test_classify_real.py
vendored
7
api/tests/fixtures/test_classify_real.py
vendored
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user