feat(osv): код счёта «по счету: X», строка вида субконто, «План счетов» в шапке (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e625409818
commit
29f264d842
@ -4,6 +4,8 @@ mapping:
|
||||
- ['^(счета?|субконто):?$', ACCOUNT]
|
||||
- ['^подразделени', DEPARTMENT]
|
||||
- ['^контрагент', COUNTERPARTY]
|
||||
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10)
|
||||
- ['^организаци', COUNTERPARTY]
|
||||
- ['^договор', CONTRACT]
|
||||
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
|
||||
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
|
||||
|
||||
@ -95,3 +95,6 @@ header_markers:
|
||||
subkonto:
|
||||
- '\bсубконто\b'
|
||||
- '\bдетализация\b'
|
||||
# плоская форма 1С: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» — организация после метки
|
||||
chart_of_accounts:
|
||||
- '\bплан\s+счетов\b'
|
||||
|
||||
@ -128,6 +128,7 @@ def _is_structural_marker(cell: _HeadCell, rules: Rules) -> bool:
|
||||
*markers["data_type"],
|
||||
*markers["org_params"],
|
||||
*markers["subkonto"],
|
||||
*markers["chart_of_accounts"],
|
||||
]
|
||||
return _find(patterns, cell.text) is not None
|
||||
|
||||
@ -254,14 +255,49 @@ def _title_tail_organization(cells: list[_HeadCell], rules: Rules) -> str | None
|
||||
return None
|
||||
|
||||
|
||||
def _kind_row_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]:
|
||||
"""Ячейки строго между строкой названия ведомости (метка `account`) и строкой периода, если название выше периода.
|
||||
|
||||
Плоская форма 1С: «по счету: 76» / «Контрагенты» / «за 2025 г.» — средняя строка называет вид субконто, а не
|
||||
организацию. Название и период в одной строке (или период выше) — таких ячеек нет.
|
||||
"""
|
||||
title = next((cell for cell in cells if _find(rules.headers.header_markers["account"], cell.text)), None)
|
||||
period = _find_period(cells, rules)
|
||||
if title is None or period is None or period.cell.row <= title.row:
|
||||
return []
|
||||
return [cell for cell in cells if title.row < cell.row < period.cell.row]
|
||||
|
||||
|
||||
def _chart_of_accounts_tail(cells: list[_HeadCell], rules: Rules) -> str | None:
|
||||
"""Организация после метки «План счетов» (хвост строки периода плоской формы 1С); пустой хвост — `None`."""
|
||||
for cell in cells:
|
||||
match = _find(rules.headers.header_markers["chart_of_accounts"], cell.text)
|
||||
if match is None:
|
||||
continue
|
||||
tail = _tail(cell.text, match).strip(" ,;:")
|
||||
if tail:
|
||||
return tail
|
||||
return None
|
||||
|
||||
|
||||
def _organization(
|
||||
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
|
||||
) -> str | None:
|
||||
"""Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе
|
||||
хвост названия ведомости.
|
||||
"""Организация: хвост метки «План счетов»; иначе первая текстовая ячейка, не являющаяся меткой шапки, строкой
|
||||
периода, строкой вида субконто, меткой валюты или типом данных; иначе хвост названия ведомости.
|
||||
"""
|
||||
tail = _chart_of_accounts_tail(cells, rules)
|
||||
if tail is not None:
|
||||
return tail
|
||||
taken = [
|
||||
cell for cell in (None if period is None else period.cell, data_type, *_currency_cells(cells, rules)) if cell
|
||||
cell
|
||||
for cell in (
|
||||
None if period is None else period.cell,
|
||||
data_type,
|
||||
*_currency_cells(cells, rules),
|
||||
*_kind_row_cells(cells, rules),
|
||||
)
|
||||
if cell
|
||||
]
|
||||
found = next(
|
||||
(cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)),
|
||||
@ -275,7 +311,7 @@ def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
|
||||
match = _find(rules.headers.header_markers["account"], cell.text)
|
||||
if match is None:
|
||||
continue
|
||||
code = _ACCOUNT_CODE.match(_tail(cell.text, match))
|
||||
code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": "))
|
||||
if code is not None:
|
||||
return code.group(0).rstrip(_CODE_TRAILING)
|
||||
return None
|
||||
@ -539,9 +575,22 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc
|
||||
groups.append(_group(group_type, label, has_inn=has_inn))
|
||||
if not groups:
|
||||
groups = [_group("ACCOUNT")]
|
||||
if len(groups) == 1:
|
||||
groups.extend(_kind_row_groups(sheet, header, rules))
|
||||
return GroupSchema(groups, 1, hint)
|
||||
|
||||
|
||||
def _kind_row_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]:
|
||||
"""Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп."""
|
||||
groups: list[OsvGroup] = []
|
||||
for cell in _kind_row_cells(_head_cells(sheet, header), rules):
|
||||
for item in (part.strip() for part in cell.text.split(_LIST_SEPARATOR)):
|
||||
known = rules.groups.known(item) if item else None
|
||||
if known is not None:
|
||||
groups.append(_group(known, item))
|
||||
return groups
|
||||
|
||||
|
||||
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||
hint = _inn_hint(items, rules)
|
||||
|
||||
@ -49,7 +49,7 @@ CHECKS_FILE = "osv_checks.yaml"
|
||||
|
||||
GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
|
||||
SumGroup = Literal["opening", "turnover", "closing"]
|
||||
HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"]
|
||||
HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto", "chart_of_accounts"]
|
||||
RowDataType = Literal["CURRENCY", "QUANTITY"]
|
||||
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
||||
|
||||
@ -193,6 +193,10 @@ class OsvGroupRules(_Model):
|
||||
found = first_match(self.mapping, text)
|
||||
return self.default if found is None else found
|
||||
|
||||
def known(self, text: str) -> GroupType | None:
|
||||
"""Тип группировки по явной записи `mapping`; не совпавшее ни с одной записью — `None` (не `default`)."""
|
||||
return first_match(self.mapping, text)
|
||||
|
||||
|
||||
class OsvCheckRules(_Model):
|
||||
accounts_without_analytic_balance: list[str]
|
||||
|
||||
@ -10,7 +10,7 @@ from gridbuilder import M, at, sheet_from_rows
|
||||
|
||||
from src.parser.detect import TableHeader, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
OPENING = "Сальдо на начало периода"
|
||||
@ -569,3 +569,77 @@ def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None:
|
||||
doc = _variant1(rules, title=_title("за неизвестно что"))
|
||||
|
||||
assert doc.period_start is None and doc.period_end is None
|
||||
|
||||
|
||||
# --- Плоская форма 1С: «по счету: X», строка вида субконто, «План счетов» ---
|
||||
|
||||
|
||||
def _flat_rows(*head: str, group_label: str = "Субконто") -> list[list[object]]:
|
||||
"""Шапка плоской формы: строки шапки, заголовок «Субконто» с группами сумм, строка данных."""
|
||||
groups_row = [M(group_label, 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)]
|
||||
subhead = at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})
|
||||
return [*[[text] for text in head], groups_row, subhead, DATA_ROW]
|
||||
|
||||
|
||||
def test_account_code_after_colon(rules: Rules) -> None:
|
||||
rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76.Н.1", "за 2025 г.", "ООО Ромашка")
|
||||
|
||||
doc = _parse(rows, rules)[2]
|
||||
|
||||
assert doc.account_code_raw == "76.Н.1"
|
||||
assert ACCOUNT_NOT_FOUND_WARNING not in doc.warnings
|
||||
|
||||
|
||||
def test_colon_without_code_falls_back_to_table_row(rules: Rules) -> None:
|
||||
rows = _flat_rows("Оборотно-сальдовая ведомость по счету:", "за 2025 г.", "ООО Ромашка")
|
||||
|
||||
doc = _parse(rows, rules)[2]
|
||||
|
||||
assert doc.account_code_raw == ""
|
||||
assert ACCOUNT_NOT_FOUND_WARNING in doc.warnings
|
||||
|
||||
|
||||
def test_kind_row_between_title_and_period_is_not_organization(rules: Rules) -> None:
|
||||
rows = _flat_rows(
|
||||
"Оборотно-сальдовая ведомость по счету: 76", "Контрагенты", "за 2025 г.", 'ООО "Товарное хозяйство"'
|
||||
)
|
||||
|
||||
assert _parse(rows, rules)[2].organization_name == 'ООО "Товарное хозяйство"'
|
||||
|
||||
|
||||
def test_organization_from_chart_of_accounts_tail(rules: Rules) -> None:
|
||||
rows = _flat_rows(
|
||||
"Оборотно-сальдовая ведомость по счету: 60",
|
||||
"Организации",
|
||||
"за 2 Квартал 2026 г. План счетов ООО Хреновская Нива",
|
||||
)
|
||||
|
||||
doc = _parse(rows, rules)[2]
|
||||
|
||||
assert doc.organization_name == "ООО Хреновская Нива"
|
||||
assert doc.period_quarter == "Q2"
|
||||
assert doc.period_year == 2026 # noqa: PLR2004
|
||||
|
||||
|
||||
def test_title_and_period_in_one_row_keep_old_organization_rule(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("3 квартал 2024 г."))
|
||||
|
||||
assert header.organization_name == 'АО "Ромашка"'
|
||||
|
||||
|
||||
def _flat_schema(rules: Rules, kind: str) -> list[tuple[str, str | None]]:
|
||||
rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76", kind, "за 2025 г.", "ООО Ромашка")
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
return [(g.type, g.name) for g in build_group_schema(sheet, header, rules)[0]]
|
||||
|
||||
|
||||
def test_known_kind_row_extends_single_account_schema(rules: Rules) -> None:
|
||||
assert _flat_schema(rules, "Контрагенты") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Контрагенты")]
|
||||
|
||||
|
||||
def test_unknown_kind_row_is_not_added_to_schema(rules: Rules) -> None:
|
||||
assert _flat_schema(rules, "Материалы") == [("ACCOUNT", "Субконто")]
|
||||
|
||||
|
||||
def test_organizations_kind_row_is_counterparty(rules: Rules) -> None:
|
||||
assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")]
|
||||
|
||||
@ -359,12 +359,20 @@ def test_credit_synonyms(rules: Rules, text: str) -> None:
|
||||
("org_params", "Параметры:"),
|
||||
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
|
||||
("subkonto", "Субконто"),
|
||||
("chart_of_accounts", "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"),
|
||||
],
|
||||
)
|
||||
def test_header_markers(rules: Rules, kind: str, text: str) -> None:
|
||||
assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index]
|
||||
|
||||
|
||||
def test_known_group_is_none_for_default(rules: Rules) -> None:
|
||||
assert rules.groups.known("Контрагенты") == "COUNTERPARTY"
|
||||
assert rules.groups.known("Материалы") is None
|
||||
assert rules.groups.known("Организации") == "COUNTERPARTY"
|
||||
assert rules.groups.known("Номенклатура") == "OTHER"
|
||||
|
||||
|
||||
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
|
||||
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
|
||||
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user