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:
parent
83dd241a00
commit
8e627e80c5
@ -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)
|
||||
|
||||
3
api/tests/fixtures/test_header_real.py
vendored
3
api/tests/fixtures/test_header_real.py
vendored
@ -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" # четыре цифры — не счёт: граница по цифрам
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user