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]
|
- ['^(счета?|субконто):?$', ACCOUNT]
|
||||||
- ['^подразделени', DEPARTMENT]
|
- ['^подразделени', DEPARTMENT]
|
||||||
- ['^контрагент', COUNTERPARTY]
|
- ['^контрагент', COUNTERPARTY]
|
||||||
|
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10)
|
||||||
|
- ['^организаци', COUNTERPARTY]
|
||||||
- ['^договор', CONTRACT]
|
- ['^договор', CONTRACT]
|
||||||
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
|
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
|
||||||
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
|
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
|
||||||
|
|||||||
@ -95,3 +95,6 @@ header_markers:
|
|||||||
subkonto:
|
subkonto:
|
||||||
- '\bсубконто\b'
|
- '\bсубконто\b'
|
||||||
- '\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["data_type"],
|
||||||
*markers["org_params"],
|
*markers["org_params"],
|
||||||
*markers["subkonto"],
|
*markers["subkonto"],
|
||||||
|
*markers["chart_of_accounts"],
|
||||||
]
|
]
|
||||||
return _find(patterns, cell.text) is not None
|
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
|
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(
|
def _organization(
|
||||||
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
|
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе
|
"""Организация: хвост метки «План счетов»; иначе первая текстовая ячейка, не являющаяся меткой шапки, строкой
|
||||||
хвост названия ведомости.
|
периода, строкой вида субконто, меткой валюты или типом данных; иначе хвост названия ведомости.
|
||||||
"""
|
"""
|
||||||
|
tail = _chart_of_accounts_tail(cells, rules)
|
||||||
|
if tail is not None:
|
||||||
|
return tail
|
||||||
taken = [
|
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(
|
found = next(
|
||||||
(cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)),
|
(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)
|
match = _find(rules.headers.header_markers["account"], cell.text)
|
||||||
if match is None:
|
if match is None:
|
||||||
continue
|
continue
|
||||||
code = _ACCOUNT_CODE.match(_tail(cell.text, match))
|
code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": "))
|
||||||
if code is not None:
|
if code is not None:
|
||||||
return code.group(0).rstrip(_CODE_TRAILING)
|
return code.group(0).rstrip(_CODE_TRAILING)
|
||||||
return None
|
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))
|
groups.append(_group(group_type, label, has_inn=has_inn))
|
||||||
if not groups:
|
if not groups:
|
||||||
groups = [_group("ACCOUNT")]
|
groups = [_group("ACCOUNT")]
|
||||||
|
if len(groups) == 1:
|
||||||
|
groups.extend(_kind_row_groups(sheet, header, rules))
|
||||||
return GroupSchema(groups, 1, hint)
|
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:
|
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
||||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||||
hint = _inn_hint(items, rules)
|
hint = _inn_hint(items, rules)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ CHECKS_FILE = "osv_checks.yaml"
|
|||||||
|
|
||||||
GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
|
GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
|
||||||
SumGroup = Literal["opening", "turnover", "closing"]
|
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"]
|
RowDataType = Literal["CURRENCY", "QUANTITY"]
|
||||||
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
||||||
|
|
||||||
@ -193,6 +193,10 @@ class OsvGroupRules(_Model):
|
|||||||
found = first_match(self.mapping, text)
|
found = first_match(self.mapping, text)
|
||||||
return self.default if found is None else found
|
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):
|
class OsvCheckRules(_Model):
|
||||||
accounts_without_analytic_balance: list[str]
|
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.detect import TableHeader, find_table_header
|
||||||
from src.parser.grid import Sheet
|
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
|
from src.parser.rules import Rules, load_rules
|
||||||
|
|
||||||
OPENING = "Сальдо на начало периода"
|
OPENING = "Сальдо на начало периода"
|
||||||
@ -569,3 +569,77 @@ def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None:
|
|||||||
doc = _variant1(rules, title=_title("за неизвестно что"))
|
doc = _variant1(rules, title=_title("за неизвестно что"))
|
||||||
|
|
||||||
assert doc.period_start is None and doc.period_end is None
|
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", "Параметры:"),
|
("org_params", "Параметры:"),
|
||||||
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
|
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
|
||||||
("subkonto", "Субконто"),
|
("subkonto", "Субконто"),
|
||||||
|
("chart_of_accounts", "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_header_markers(rules: Rules, kind: str, text: str) -> None:
|
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]
|
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:
|
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
|
||||||
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
|
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
|
||||||
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
|
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