From 8e627e80c5802f2e9067c75960e155c63739f363 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:19:18 +0400 Subject: [PATCH] =?UTF-8?q?fix(parser):=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92=20=E2=80=94=20=C2=AB=D0=B7=D0=B0=C2=BB/?= =?UTF-8?q?=C2=AB=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=B0=C2=BB=20=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=20=D1=81=20=C2=AB=D0=B3.=C2=BB=20(T5.1=E2=80=93T5.?= =?UTF-8?q?5,=20T5.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T5.1: организация исключается только по структурным меткам (лист, счёт, тип данных, параметры, субконто), строка периода выбирается по опознанному словарём периоду, метка валюты — только с двоеточием - T5.2: конец периода — самое короткое начало, которое узнаёт словарь; диапазон «01.01.2025 г. - 31.03.2025 г.» цел - T5.3: граница по цифрам в правиле счёта из имени файла - T5.4: тест has_inn для варианта 2 (правило оставлено); T5.5: период только датами -> "" + предупреждение - T5.7: тест обязательного ключа periods.end Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/parser/header.py | 140 ++++++++++++++++++------- api/tests/fixtures/test_header_real.py | 3 +- api/tests/unit/parser/test_header.py | 102 ++++++++++++++++++ api/tests/unit/parser/test_rules.py | 22 ++++ 4 files changed, 230 insertions(+), 37 deletions(-) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index fdd54a9..f645d79 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -112,16 +112,95 @@ def _head_cells(sheet: Sheet, header: TableHeader) -> list[_HeadCell]: ] -def _is_marker(cell: _HeadCell, rules: Rules) -> bool: +def _is_structural_marker(cell: _HeadCell, rules: Rules) -> bool: + """Структурные метки шапки: лист, счёт, тип данных, параметры, субконто. + + Голые «за» и «валюта» меткой не считаются: это обычные слова, которые могут стоять в названии организации + («ООО «За Родину»», «Валюта Трейд»). Период и валюта опознаются отдельно (`_find_period`, `_currency_cells`). + """ + markers = rules.headers.header_markers patterns = [ *rules.headers.sheet_markers, - *(pattern for group in rules.headers.header_markers.values() for pattern in group), + *markers["account"], + *markers["data_type"], + *markers["org_params"], + *markers["subkonto"], ] return _find(patterns, cell.text) is not None -def _organization(cells: list[_HeadCell], rules: Rules) -> _HeadCell | None: - return next((cell for cell in cells if not _is_marker(cell, rules)), None) +def _is_title(cell: _HeadCell, rules: Rules) -> bool: + """Строка названия ведомости («Оборотно-сальдовая ведомость по счету … за …»): признак листа или метка счёта.""" + patterns = [*rules.headers.sheet_markers, *rules.headers.header_markers["account"]] + return _find(patterns, cell.text) is not None + + +def _currency_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]: + """Строки-метки валюты: «Валюта БУ, НУ: RUB» (с двоеточием; «Валюта Трейд» — не метка).""" + patterns = rules.headers.header_markers["currency"] + return [cell for cell in cells if _LABEL_SEPARATOR in cell.text and _find(patterns, cell.text) is not None] + + +@dataclass(frozen=True) +class _PeriodHit: + cell: _HeadCell + text: str + + +def _cut_period(tail: str, rules: Rules) -> str: + """Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт. + + Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно + начало не узнано, период — весь хвост до конца строки. + """ + start = 0 + while start < len(tail): + end = rules.periods.end.search(tail[start:]) + if end is None: + break + start += _source_offset(tail[start:], end.end()) + candidate = tail[:start].strip() + if rules.periods.match(candidate) is not None: + return candidate + return tail + + +def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]: + """Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат. + + Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать. + """ + hits: list[_PeriodHit] = [] + for cell in cells: + match = _find(rules.headers.header_markers["period"], cell.text) + if match is None: + continue + tail = _tail(cell.text, match) + if tail: + hits.append(_PeriodHit(cell, _cut_period(tail, rules))) + return hits + + +def _is_recognised(text: str, rules: Rules) -> bool: + return rules.periods.match(text) is not None or rules.periods.year.search(text) is not None + + +def _find_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None: + """Строка периода: сначала с опознанным периодом, затем строка названия ведомости или метка с двоеточием.""" + hits = _period_candidates(cells, rules) + recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None) + if recognised is not None: + return recognised + return next((hit for hit in hits if _is_title(hit.cell, rules) or _LABEL_SEPARATOR in hit.cell.text), None) + + +def _organization(cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None) -> _HeadCell | None: + """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода или меткой валюты.""" + taken = [cell for cell in (None if period is None else period.cell, *_currency_cells(cells, rules)) if cell] + return next( + (cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)), + None, + ) def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: @@ -135,35 +214,20 @@ def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: return None -def _period_text(cells: list[_HeadCell], skip: _HeadCell | None, rules: Rules) -> str | None: - """Текст после метки периода («за …», «Период: …») до «г.» включительно или до конца строки.""" - for cell in cells: - if cell is skip: - continue - match = _find(rules.headers.header_markers["period"], cell.text) - if match is None: - continue - tail = _tail(cell.text, match) - end = rules.periods.end.search(tail) - if end is not None: - tail = tail[: _source_offset(tail, end.end())].strip() - if tail: - return tail - return None +def _value_in_row(sheet: Sheet, cell: _HeadCell, patterns: list[Pattern]) -> str: + """Значение метки: после двоеточия в той же ячейке, а если там пусто — в следующей ячейке строки.""" + match = _find(patterns, cell.text) + value = _value_after_colon(_tail(cell.text, match)) if match is not None else "" + if value: + return value + neighbours = [other.text for col, other in sheet.row_cells(cell.row) if col > cell.col] + return neighbours[0] if neighbours else "" def _labelled_value(sheet: Sheet, cells: list[_HeadCell], patterns: list[Pattern]) -> str | None: - """Значение строки с меткой: после двоеточия в той же ячейке, а если там пусто — в следующей ячейке строки.""" - for cell in cells: - match = _find(patterns, cell.text) - if match is None: - continue - value = _value_after_colon(_tail(cell.text, match)) - if value: - return value - neighbours = [other.text for col, other in sheet.row_cells(cell.row) if col > cell.col] - return neighbours[0] if neighbours else "" - return None + """Значение первой строки с меткой (`None` — метки нет).""" + cell = next((cell for cell in cells if _find(patterns, cell.text) is not None), None) + return None if cell is None else _value_in_row(sheet, cell, patterns) def _subkonto_items(cells: list[_HeadCell], rules: Rules) -> list[str] | None: @@ -216,13 +280,16 @@ def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warning def _parse_currency(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> str | None: - value = _labelled_value(sheet, cells, rules.headers.header_markers["currency"]) - if value is None: + patterns = rules.headers.header_markers["currency"] + candidates = _currency_cells(cells, rules) + if not candidates: warnings.append("валюта в шапке не найдена") return None - code = rules.data_types.currency_code(value) + values = [_value_in_row(sheet, cell, patterns) for cell in candidates] + # Метка валюты, значение которой опознано, важнее первой попавшейся + code = next((found for found in (rules.data_types.currency_code(value) for value in values) if found), None) if code is None: - warnings.append(f"валюта «{value}» не распознана") + warnings.append(f"валюта «{values[0]}» не распознана") return code @@ -231,13 +298,14 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead cells = _head_cells(sheet, header) warnings: list[str] = [] - organization = _organization(cells, rules) + period_hit = _find_period(cells, rules) + organization = _organization(cells, rules, period_hit) if organization is None: warnings.append("наименование организации в шапке не найдено") account = _account_code(cells, rules) if account is None: warnings.append("код счёта (после «по счету») в шапке не найден") - period_text = _period_text(cells, organization, rules) + period_text = None if period_hit is None else period_hit.text if period_text is None: warnings.append("период (после «за» или «Период:») в шапке не найден") period = _Period(None, None, None) diff --git a/api/tests/fixtures/test_header_real.py b/api/tests/fixtures/test_header_real.py index d813936..55994a7 100644 --- a/api/tests/fixtures/test_header_real.py +++ b/api/tests/fixtures/test_header_real.py @@ -20,7 +20,7 @@ from src.parser.rules import Rules, load_rules # Первое число имени файла — счёт: «ОСВ_58 за 2024», «сч 01», «по счету 62 за 3 квартал»; «583» — три цифры — # счёт и субсчёт без точки («58», субсчёт «3» -> «58.03») -ACCOUNT_IN_NAME = re.compile(r"\d{2,3}(?:\.\d{1,2})?") +ACCOUNT_IN_NAME = re.compile(r"(? None: assert expected_account(Path("ОСВ_01_ОС по субсчетам.xlsx")) == "01" assert expected_account(Path("ОСВ_58 за 2024.xls")) == "58" assert expected_account(Path("сч 66_3 кв 2024.xlsx")) == "66" + assert expected_account(Path("отчет 2024 сч 60.xlsx")) == "60" # четыре цифры — не счёт: граница по цифрам diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index c472039..238fe7a 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -3,6 +3,8 @@ Сетки собираются `gridbuilder`; шапка и заголовок таблицы находятся по содержимому, позиции в сетках произвольные. """ +import datetime + import pytest from gridbuilder import M, at, sheet_from_rows @@ -376,3 +378,103 @@ def test_variant2_detail_row_is_not_taken_for_organization(rules: Rules) -> None doc = _parse(rows, rules)[2] assert doc.organization_name == "" + + +# --- Название организации со словами «за» и «валюта» (T5.1) --- + + +@pytest.mark.parametrize("organization", ["ООО «За Родину»", "Валюта Трейд", "За Урожай ЗАО"]) +def test_organization_name_with_marker_words_does_not_hijack_period_or_currency( + rules: Rules, organization: str +) -> None: + header = _variant1(rules, org=organization, title=_title("3 квартал 2024 г.")) + + assert header.organization_name == organization + assert header.period_text == "3 квартал 2024 г." + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2024, "Q3", False) + assert header.currency == "RUB" + assert header.warnings == () + + +def test_organization_with_currency_word_and_no_currency_row_is_still_organization(rules: Rules) -> None: + rows: list[list[object]] = [["Валюта Трейд"], [_title("2024 г.")], *_table(["Контрагенты"])] + + header = _parse(rows, rules)[2] + + assert header.organization_name == "Валюта Трейд" + assert header.currency is None + assert any("алют" in warning for warning in header.warnings) + assert header.period_quarter == "Q4" + + +# --- Диапазоны дат с «г.» (T5.2) --- + + +@pytest.mark.parametrize( + ("period", "quarter"), + [ + ("01.01.2025 г. - 31.03.2025 г.", "Q1"), + ("с 01.01.2025 г. по 30.06.2025 г.", "Q2"), + ("01.01.2025 г. по 31.12.2025 г.", "Q4"), + ("01.01.2025 - 30.09.2025 г. (по подразделениям)", "Q3"), + ], +) +def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, quarter: str) -> None: + header = _variant1(rules, title=_title(period)) + + assert header.period_text == period.removesuffix(" (по подразделениям)") + assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, True, 2025) + assert header.warnings == () + + +# --- Даты числами / датами (xlsb) (T5.5) --- + + +def test_period_carried_only_by_date_cells_is_not_read(rules: Rules) -> None: + """Период «за» + ячейки-даты (xlsb не отдаёт форматы, даты — числа): текста периода нет, это не ошибка разбора.""" + rows: list[list[object]] = [ + ["Организация"], + ["Оборотно-сальдовая ведомость по счету 60 за", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31)], + *_table(["Контрагенты"]), + ] + + header = _parse(rows, rules)[2] + + assert header.organization_name == "Организация" + assert header.account_code_raw == "60" + assert header.period_text == "" + assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None) + assert any("период" in warning for warning in header.warnings) + + +# --- Вариант 2: ИНН (T5.4) --- + + +def _variant2_with_inn_column() -> list[list[object]]: + rows = _variant2_rows() + rows[6] = [ + "", + None, + "ИНН", + None, + *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[4:], + ] + return rows + + +def test_variant2_inn_column_marks_the_first_counterparty(rules: Rules) -> None: + sheet, header, _ = _parse(_variant2_with_inn_column(), rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert header.inn_col is not None + assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("COUNTERPARTY", True), ("CONTRACT", None)] + + +def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None: + sheet, header, _ = _parse(_variant2_rows(), rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert header.inn_col is None + assert all(g.has_inn is None for g in groups) diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 1986b7c..108ab55 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -162,6 +162,28 @@ def test_missing_required_key_raises_runtime_error_with_file_and_key(tmp_path: P assert "groups" in str(error.value) +def test_periods_without_end_marker_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None: + target = _copy_rules(tmp_path) + path = target / "periods.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + del data["end"] + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError) as error: + load_rules(target) + + assert "periods.yaml" in str(error.value) + assert "end" in str(error.value) + + +def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules: Rules) -> None: + end = rules.periods.end + + assert end.search("3 квартал 2024 г. (по подразделениям)") is not None + assert end.search("2024 год") is not None + assert end.search("с 01.01.2025 по 31.03.2025") is None + + def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None: target = _copy_rules(tmp_path) path = target / "periods.yaml"