feat(osv): код счёта «по счету: X», строка вида субконто, «План счетов» в шапке (#59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 11:47:53 +04:00
parent e625409818
commit 29f264d842
6 changed files with 146 additions and 6 deletions

View File

@ -4,6 +4,8 @@ mapping:
- ['^(счета?|субконто):?$', ACCOUNT]
- ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY]
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10)
- ['^организаци', COUNTERPARTY]
- ['^договор', CONTRACT]
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)

View File

@ -95,3 +95,6 @@ header_markers:
subkonto:
- '\bсубконто\b'
- '\bдетализация\b'
# плоская форма 1С: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» — организация после метки
chart_of_accounts:
- '\bплан\s+счетов\b'

View File

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

View File

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

View File

@ -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", "Организации")]

View File

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