fix(parser): шапка ОСВ — «за»/«валюта» в названии организации, диапазоны дат с «г.» (T5.1–T5.5, T5.7)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:19:18 +04:00
parent 83dd241a00
commit 8e627e80c5
4 changed files with 230 additions and 37 deletions

View File

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

View File

@ -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"(?<!\d)\d{2,3}(?:\.\d{1,2})?(?!\d)")
SUBACCOUNT_WITHOUT_DOT = 3 # цифр в коде «счёт + субсчёт» без точки
ACCOUNT_DIGITS = 2 # цифр в корне счёта
SUBACCOUNT_DIGITS = 2 # цифр в записи субсчёта: «58.03»
@ -111,3 +111,4 @@ def test_expected_account_rule() -> 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" # четыре цифры — не счёт: граница по цифрам

View File

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

View File

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