Merge pull request 'ОСВ других учётных систем: названия, запасной признак, группы, «ВСЕГО» (AURORA-1717, 1719, 1732)' (#82) from feat/81-osv-other-systems into main

This commit is contained in:
aep 2026-10-28 20:28:49 +03:00
commit 56af97cdb9
17 changed files with 1282 additions and 24 deletions

View File

@ -75,7 +75,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 136 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 41 синтетический) — регрессия, а не
- Признак листа ОСВ шире названия ведомости — запасной признак по заголовку таблицы (`detect.has_osv_table`, #81).
Его ограничения (три группы, Дт и Кт рядом, первые 30 строк, без свёрнутого сальдо) держат негативы синтетики
s55–s57: ослабили ограничение — падает свой негатив. Новое ослабление — только с новым негативом.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 149 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 54 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -121,7 +121,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl; части архива, отличающиеся от ссылок `[Content_Types].xml`/`*.rels` только регистром (`xl/SharedStrings.xml`), до openpyxl переименовываются по ссылкам, как их находит Excel — AURORA-1707; xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») |
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее; ОСВ — по названию ведомости `sheet_markers` или, если лист не форма, по полному заголовку таблицы `has_osv_table`: три группы, у каждой «Дебет» и «Кредит» рядом, в первых 30 строках, #81), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `header_account` → `sums`; `trivial` — меньше двух строк данных; `header_account` — плоский список под счётом шапки (код счёта в шапке, строк-счетов в теле нет; плоская форма 1С, #59); выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) |
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |

View File

@ -7,29 +7,54 @@
# ведомость», «д т», `\w` вместо диапазона;
# - новый синоним = новая строка в нужном списке, без правки кода.
# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов
# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов. Названия других учётных систем и
# отчётов 1С (AURORA-1717) — только с начала ячейки: «… см. оборотную ведомость» в примечании формы листом ОСВ не делает
sheet_markers:
- 'оборотно\s*сальдовая\s+ведомость'
- '^оборотн\w*\s+ведомост' # «Оборотная ведомость по счетам …» (выгрузки КТЗ)
# «ОСВ по сч. 41 за 3 кв.», «ОСВ по ДЗ по сч. 62.01» (КТЗ); не «ОСВ за квартал по счетам …» в перечне документов
- '^осв\s+по\s+(\w+\s+по\s+)?сч\b'
- '^оборотн\w*\s+баланс' # «Оборотный баланс» (КТЗ)
- '^фин\.?\s*обороты' # «Фин. Обороты Листок» (Navision, Айсберри)
- '^отчет\s+по\s+главной\s+книге' # SAP, ООО «Виола»
# Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover:
# отсюда границы слова `\bоборот(ы)?\b`.
# отсюда границы слова `\bоборот(ы)?\b`. Сторона может стоять в ячейке группы после названия — в том числе вплотную
# («НачалоДт», «ОборотыДтВВ»): паттерн группы тогда заканчивается перед стороной (опережающая проверка), остаток ячейки
# разбирают `debit`/`credit` (AURORA-1719)
groups:
opening:
- 'сальдо\s+на\s+начало'
- '\bнач(альн\w*)?\.?\s*сальдо'
- 'входящ\w*\s+сальдо'
- 'остат\w*\s+на\s+начало' # «Остаток на начало (периода)» (КТЗ)
- 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+начало)' # «Остаток по дебету на начало» (КТЗ, ЧАЗ)
- 'сальдо\s+начальное' # «Сальдо НачальноеДТВВ» (SAP, НМЖК)
- 'сальдо\s+вх\b' # «Сальдо вх Дт» (SAP, Виола)
- '^начало(?=\s*(дт|кт)\b)' # «НачалоДт» (Navision без названия ведомости, Айсберри)
turnover:
- '\bоборот(ы)?\b'
- '^оборот(ы)?(?=\s*(дт|кт))' # «ОборотДт» (Navision), «ОборотыДтВВ» (SAP, НМЖК)
closing:
- 'сальдо\s+на\s+конец'
- '\bкон(ечн\w*)?\.?\s*сальдо'
- 'исходящ\w*\s+сальдо'
- 'остат\w*\s+на\s+конец' # «Остаток на конец (периода)» (КТЗ)
- 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+конец)' # «Остаток по кредиту на конец» (КТЗ, ЧАЗ)
- 'сальдо\s+конечное' # «Сальдо КонечноеКТВВ» (SAP, НМЖК)
- 'сальдо\s+исх\b' # «Сальдо исх Кт» (SAP, Виола)
- '^конец(?=\s*(дт|кт)\b)' # «КонецКт» (Navision без названия ведомости, Айсберри)
# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т»): ячейка начинается с синонима
# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т») или сторона в остатке ячейки группы:
# ячейка (остаток) начинается с синонима
debit:
- '^(дебет|дт|д т)\b'
- '^по\s+дебету\b' # «Оборот по дебету», «Остаток по дебету на начало» (КТЗ)
- '^дт\s*вв\b' # «Сальдо НачальноеДТВВ» (SAP, НМЖК)
credit:
- '^(кредит|кт|к т)\b'
- '^по\s+кредиту\b' # «Оборот по кредиту» (КТЗ)
- '^кт\s*вв\b' # «Сальдо НачальноеКТВВ» (SAP, НМЖК)
# Служебные колонки таблицы по тексту заголовка. Код знает колонки ИНН и «Показатели»; прочие колонки таблицы (КПП, код,
# количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют.
@ -52,6 +77,8 @@ total_row_exact:
- '^итого$'
# выгрузка 1С «по контрагентам»: итог листа — «ИТОГ» (без «О»); «ИТОГ по 60.111» — итог субсчёта (subaccount_sums)
- '^итог$'
# «ВСЕГО» — итог листа ведомостей ГК КТЗ по счёту 009 (AURORA-1732)
- '^всего$'
# Итог субсчёта в выгрузке 1С «по контрагентам»: «ИТОГ по 60.111» — суммы строки-счёта с тем же кодом (группа account);
# отдельной строкой в результат не попадает (05-domain-rules.md §1.3, AURORA-1714)

View File

@ -35,7 +35,7 @@ from src.contract import (
)
from src.parser import ParseOutcome
from src.parser.deadline import Deadline
from src.parser.detect import is_osv_sheet
from src.parser.detect import has_osv_table, is_osv_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.osv import OsvParser, data_rows
from src.parser.osv_warnings import OSV_XLSB_NO_OUTLINE, OsvNote
@ -143,7 +143,13 @@ class BookParser:
if read.no_codepage:
warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules)))
sheets = read.sheets
plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline)
plan = plan_sheets(
sheets,
lambda sheet: is_osv_sheet(sheet, self._rules),
rules,
deadline,
osv_by_table=lambda sheet: has_osv_table(sheet, self._rules),
)
osv_names = {sheet.name for sheet in plan.osv}
other_names = {sheet.name for sheet in plan.other}
reader_notes = _reader_notes(read)

View File

@ -30,6 +30,7 @@ __all__ = [
"classify_sheet",
"find_table_header",
"first_data_row",
"has_osv_table",
"inn_from_cell",
"is_header_repeat",
"is_osv_sheet",
@ -166,13 +167,39 @@ def is_osv_sheet(sheet: Sheet, rules: Rules) -> bool:
def classify_sheet(sheet: Sheet, rules: Rules, statement_rules: StatementRules | None = None) -> SheetKind:
"""ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по словарям
пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию)."""
пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию); «прочее» с полным заголовком
таблицы ОСВ — ОСВ (`has_osv_table`)."""
if is_osv_sheet(sheet, rules):
return SheetKind.OSV
# цикл импорта: forms → cells → number_format → numbers → detect (INN_LENGTHS, inn_from_cell)
from src.parser.statements.forms import classify_statement_sheet # noqa: PLC0415
return _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())]
kind = _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())]
if kind is SheetKind.OTHER and has_osv_table(sheet, rules):
return SheetKind.OSV
return kind
def has_osv_table(sheet: Sheet, rules: Rules) -> bool:
"""Запасной признак листа ОСВ без названия ведомости (#81, design D1): в первых `SHEET_MARKER_ROWS` строках есть
строка со всеми тремя группами сумм, и у каждой группы — колонки «Дебет» и «Кредит» (свёрнутое сальдо не годится,
R-3), между которыми в строках заголовка нет других подписей (R-11: у отчёта 1С «Обороты счета» между «Оборот Дт» и
«Оборот Кт» — колонки корр. счетов, это не ОСВ). Проверяется та строка групп, которую возьмёт `find_table_header`
(первая с «оборотами» и «концом»), — иначе преамбула «Обороты: …» над шапкой уронила бы разбор листа; колонки
ищутся тем же разбором, что у обычного заголовка (R-6)."""
for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1):
cells = _group_cells(sheet, row, rules)
groups = {cell.group for cell in cells}
if not REQUIRED_GROUPS <= groups:
continue
if groups != set(GROUP_ORDER):
return False
try:
columns = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER]
except InvalidFormatError:
return False
return all(_sides_side_by_side(sheet, row, group_columns) for group_columns in columns)
return False
# --- Заголовок таблицы ---
@ -311,6 +338,18 @@ def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit:
return _GroupColumns(debit, credit, None)
def _sides_side_by_side(sheet: Sheet, group_row: int, columns: _GroupColumns) -> bool:
"""У группы найдены «Дебет» и «Кредит», и между их колонками в строках заголовка (от строки групп до нижней строки
подзаголовков) нет других непустых ячеек."""
if columns.debit is None or columns.credit is None:
return False
left, right = sorted((columns.debit, columns.credit), key=lambda cell: cell.col)
bottom = max(left.bottom, right.bottom)
return not any(
left.right < col < right.col for row in range(group_row, bottom + 1) for col, _ in _texts(sheet, row)
)
def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool:
"""Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто.

View File

@ -54,10 +54,16 @@ class SheetPlan:
def plan_sheets(
sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline
sheets: Sequence[Sheet],
is_osv: Callable[[Sheet], bool],
rules: StatementRules,
deadline: Deadline,
osv_by_table: Callable[[Sheet], bool] | None = None,
) -> SheetPlan:
"""Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа
формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после»."""
"""Листы по порядку книги: ОСВ; форма; продолжение; ОСВ по заголовку таблицы (`osv_by_table`, запасной признак
#81: только для листа, который не форма и не продолжение по признакам пакета, — раньше слабого продолжения, R-12);
слабое продолжение — лист из 1–2 кодов сразу после листа формы или продолжения; прочее. Лист ОСВ или «прочее»
прерывает «сразу после»."""
osv: list[Sheet] = []
statements: list[tuple[Sheet, SheetKind]] = []
other: list[Sheet] = []
@ -75,6 +81,9 @@ def plan_sheets(
last_type = blocks[-1].type_hint if blocks else None
elif kind == "continuation":
statements.append((sheet, SheetKind.STATEMENT_CONTINUATION))
elif osv_by_table is not None and osv_by_table(sheet):
osv.append(sheet)
last_type = None
elif last_type is not None and is_weak_continuation(sheet, rules, last_type):
statements.append((sheet, SheetKind.STATEMENT_CONTINUATION))
else:

View File

@ -6,6 +6,8 @@
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78);
s48…s57 — ОСВ других учётных систем (#81): позитивы s48…s54 (названия, группы и стороны, Navision без названия, xls и
xlsb), негативы s55…s57 (почти-ОСВ, которые обязаны остаться прочими листами или формой);
описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
и zip постоянны, порядок записи фиксирован.
@ -214,6 +216,15 @@ class OsvSpec:
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная)
labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
# Название ведомости вместо «Оборотно-сальдовая ведомость по счету … за …» (вариант 1) и его колонка (#81)
title: str | None = None
title_col: int = 1
untitled: bool = False # нет организации, названия и параметров: заголовок таблицы с `1 + top_padding` (#81)
top_padding: int = 0
# Группы, подписанные двумя ячейками со стороной («НачалоДт» | «НачалоКт») вместо названия над «Дебет/Кредит»;
# разделены все группы — строки подзаголовков нет (#81)
split_groups: dict[str, tuple[str, str]] = field(default_factory=dict)
total_text: str | None = None # подпись итоговой строки; None — «Итого»
GROUP_TITLES = {
@ -247,27 +258,40 @@ def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_labe
"""
titles = spec.group_titles or GROUP_TITLES
below = spec.labels_below and bool(label)
groups = _groups(spec)
one_row = all(key in spec.split_groups for key, _, _ in groups) # заголовок в одну строку, без «Дебет/Кредит»
label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП
sheet.put(label_row, 1, label)
sheet.put(label_row + 1, 1, sub_label)
service_row = label_row + 1 if below else top + 1
if not one_row:
sheet.put(label_row + 1, 1, sub_label)
service_row = label_row + 1 if below else (top if one_row else top + 1)
if spec.inn_col is not None:
sheet.put(service_row, spec.inn_col, spec.inn_header)
if spec.kpp_col is not None:
sheet.put(service_row, spec.kpp_col, "КПП")
for key, col, width in _groups(spec):
for key, col, width in groups:
if key in spec.split_groups:
debit, credit = spec.split_groups[key]
sheet.put(top, col, debit)
sheet.put(top, col + 1, credit)
continue
sheet.put(top, col, titles[key])
sheet.merge(top, col, top, col + width - 1)
if width == PAIR_WIDTH:
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
return top + 4 if below else top + 2
if below:
return top + 4
return top + 1 if one_row else top + 2
def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
if spec.untitled:
return _header_rows(sheet, spec, 1 + spec.top_padding, "Счет", "Контрагенты")
if spec.org_name is not None:
sheet.put(1, 1, spec.org_name)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
title = spec.title or f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}"
sheet.put(2, spec.title_col, title)
sheet.put(4, 1, spec.data_type_line)
sheet.put(7, 1, "Параметры:")
sheet.put(7, 3, spec.currency_line)
@ -375,7 +399,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
_row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank)
next_row += 1
total = SheetSpec("")
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
_row_cells(total, spec, 1, (Node(spec.total_text or TOTAL_TEXT), 0), root.six())
for (_, col), cell in total.cells.items():
sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts)
return sheet
@ -418,6 +442,16 @@ def statement_sheet(name: str = "Бухгалтерский баланс") -> Sh
return sheet
def table_sheet(name: str, rows: Sequence[Sequence[str | int | D | None]], *, start_row: int = 1) -> SheetSpec:
"""Лист «как есть»: строки ячеек с `start_row`, `None` — пустая ячейка (листы-негативы #81)."""
sheet = SheetSpec(name)
for row, values in enumerate(rows, start=start_row):
for col, value in enumerate(values, start=1):
if value is not None:
sheet.put(row, col, value)
return sheet
def form_sheet(name: str, *lines: str) -> SheetSpec:
"""Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности)."""
sheet = SheetSpec(name)
@ -1119,6 +1153,235 @@ def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec
return build
# --- ОСВ других учётных систем (#81): контрольный набор. s48–s54 — позитивы, s55–s57 — негативы ---
PERIOD_TAIL = "по счету 60 за 1 квартал 2025 г."
NAVISION_GROUPS = {
"opening": ("НачалоДт", "НачалоКт"),
"turnover": ("ОборотДт", "ОборотКт"),
"closing": ("КонецДт", "КонецКт"),
}
def _titled(name: str, title: str, *, title_col: int = 1, total_text: str | None = None) -> SheetSpec:
spec = OsvSpec(inn_col=5, first_sum_col=7, title=title, title_col=title_col, total_text=total_text)
return build_osv(spec, base_tree(), name)
def _s48() -> list[SheetSpec]:
"""Названия ведомостей других систем и отчётов 1С (AURORA-1717) и итог «ВСЕГО» (AURORA-1732): по листу на название;
название — в разных колонках, верхним регистром с переносом строки."""
return [
_titled("ОВ", f"Оборотная ведомость {PERIOD_TAIL}", title_col=3),
_titled("ОСВ сокр", "ОСВ по сч. 60 за 1 квартал 2025 г."),
_titled("Оборотный баланс", f"Оборотный баланс {PERIOD_TAIL}", title_col=7),
_titled("Навижн", "Фин. Обороты Листок за 1 квартал 2025 г.", title_col=4),
_titled("Главная книга", f"Отчет по Главной книге {PERIOD_TAIL}", title_col=3, total_text="ВСЕГО"),
_titled("Верхний регистр", f"ОБОРОТНАЯ\nВЕДОМОСТЬ {PERIOD_TAIL}"),
_titled("Вторая колонка", f"Оборотная ведомость по счетам {PERIOD_TAIL}", title_col=2, total_text="Всего "),
]
def _s49_sheets(hierarchy: Hierarchy = "outline") -> list[SheetSpec]:
"""«Остаток на начало/конец» над «Дебет/Кредит» и «Оборот по дебету/кредиту» в ячейке группы (КТЗ); преамбула
«Остаток на начало: …» над таблицей; второй лист — «Остаток по дебету на начало» (сторона внутри названия)."""
first = build_osv(
OsvSpec(
inn_col=5,
first_sum_col=7,
hierarchy=hierarchy,
title=f"Оборотная ведомость {PERIOD_TAIL}",
group_titles={
**GROUP_TITLES,
"opening": "Остаток на начало периода",
"closing": "Остаток на конец периода",
},
split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")},
),
base_tree(),
"Остатки",
)
first.put(5, 1, "Остаток на начало: 0")
first.put(5, 3, "Остаток на конец: 0")
second = build_osv(
OsvSpec(
inn_col=None,
first_sum_col=3,
hierarchy=hierarchy,
title=f"Оборотная ведомость {PERIOD_TAIL}",
split_groups={
"opening": ("Остаток по дебету на начало", "Остаток по кредиту на начало"),
"turnover": ("Оборот по дебету", "Оборот по кредиту"),
"closing": ("Остаток по дебету на конец", "Остаток по кредиту на конец"),
},
),
receivables_tree(),
"Остатки по сторонам",
)
return [first, second]
def _s49() -> list[SheetSpec]:
return _s49_sheets()
def _s50() -> list[SheetSpec]:
"""SAP: «Сальдо вх/исх Дт/Кт» (Виола, обороты 1С-вида — «Дт оборот» это AURORA-1722) и сторона, приклеенная к
названию группы «Сальдо НачальноеДТВВ» / «ОборотыДтВВ» (НМЖК); контрагент «ООО Дтвв-Сервис» — не сторона."""
sap = OsvSpec(
inn_col=None,
first_sum_col=3,
title=f"Отчет по Главной книге {PERIOD_TAIL}",
split_groups={
"opening": ("Сальдо вх Дт", "Сальдо вх Кт"),
"turnover": ("Оборот Дт", "Оборот Кт"),
"closing": ("Сальдо исх Дт", "Сальдо исх Кт"),
},
)
glued = OsvSpec(
inn_col=None,
first_sum_col=3,
split_groups={
"opening": ("Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ"),
"turnover": ("ОборотыДтВВ", "ОборотыКтВВ"),
"closing": ("Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"),
},
)
tree = base_tree()
tree.kids[0].kids[0].text = "ООО Дтвв-Сервис"
return [build_osv(sap, base_tree(), "SAP"), build_osv(glued, tree, "НМЖК")]
def _navision_sheet(name: str = "ОВ", hierarchy: Hierarchy = "outline", top_padding: int = 0) -> SheetSpec:
spec = OsvSpec(
untitled=True,
top_padding=top_padding,
inn_col=None,
first_sum_col=2,
hierarchy=hierarchy,
split_groups=NAVISION_GROUPS,
)
return build_osv(spec, base_tree(), name)
def _s51() -> list[SheetSpec]:
"""Navision без названия ведомости: таблица с первой строки, «НачалоДт | … | КонецКт» (Айсберри); запасной
признак."""
return [_navision_sheet()]
def _s52() -> list[SheetSpec]:
"""Форма ББ, лист Navision без названия, посторонний лист: форма остаётся формой, «Справка» — прочим."""
return [statement_sheet(), _navision_sheet(), reference_sheet()]
def _s53() -> list[SheetSpec]:
"""s49 в xls (BIFF5 без объединений и outline): отступ пробелами."""
return _s49_sheets("spaces")
def _s54() -> list[SheetSpec]:
"""s51 в xlsb (без объединений и outline): отступ пробелами."""
return [_navision_sheet(hierarchy="spaces")]
def _s55() -> list[SheetSpec]:
"""Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «ОСВ за квартал по счетам …» в
перечне документов шаблона банка, «Оборотный капитал»), и отчёт 1С «Обороты счета» с колонками корр. счетов между
«Оборот Дт» и «Оборот Кт» (R-1) — все листы прочие."""
return [
build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"),
table_sheet(
"Справка",
[
["Справка: оборотная ведомость прилагается"],
[],
["Показатель", "Сумма"],
["Выручка", D("100000.00")],
],
),
table_sheet(
"Входные данные",
[
["Перечень документов к заявке"],
["№", "Документ", "Срок"],
[1, "Расшифровка дебиторской задолженности", "ежеквартально"],
[2, "ОСВ за квартал по счетам бухгалтерского учета 02, 58, 60, 62, 66, 67, 76", "ежеквартально"],
],
),
table_sheet(
"Капитал",
[
["Оборотный капитал и его структура"],
[],
["Показатель", "На начало", "На конец"],
["Запасы", D("500.00"), D("700.00")],
],
),
table_sheet(
"Обороты счета",
[
["Обороты счета 60 за 9 месяцев 2025 г."],
[],
["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"]
+ ["20", "Конечное сальдо Дт", "Конечное сальдо Кт"],
["60", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")],
["Итого", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")],
],
),
]
def _s56() -> list[SheetSpec]:
"""Негатив: безымянные таблицы, почти ОСВ — расшифровка «Остаток на начало | Поступило | Выбыло | Остаток на
конец», нет группы «начало», свёрнутое начало, полный заголовок ниже 30-й строки — все прочие (D1, R-2, R-3)."""
return [
build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"),
table_sheet(
"Расшифровка",
[
["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"],
["Материалы", D("100.00"), D("50.00"), D("30.00"), D("120.00")],
],
),
table_sheet(
"Без начала",
[
["Счет", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"],
["60", D("10.00"), D("5.00"), D("5.00"), None],
],
),
table_sheet(
"Свёрнутое начало",
[
["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"],
["60", D("100.00"), D("10.00"), D("5.00"), D("105.00"), None],
],
),
_navision_sheet("Ниже 30", top_padding=30),
_preamble_navision_sheet(),
]
def _preamble_navision_sheet() -> SheetSpec:
"""Полная шапка Navision, над ней преамбула «Обороты за период: … | Остаток на конец: …»: разбор берёт строку
преамбулы, значит, и запасной признак смотрит её — лист прочий, книга не падает (ревью #81, Important-1)."""
sheet = _navision_sheet("Преамбула", top_padding=1)
sheet.put(1, 1, "Обороты за период: 50")
sheet.put(1, 2, "Остаток на конец: 150")
return sheet
def _s57() -> list[SheetSpec]:
"""Негатив: форма ББ с примечанием «см. оборотную ведомость …» и текстом «Остаток на начало периода …», за ней
лист слабого продолжения (одна строка с кодом) — форма и продолжение остаются формой."""
form = statement_sheet()
form.put(9, 1, "Примечание: см. оборотную ведомость по сч. 60")
form.put(10, 1, "Остаток на начало периода — по данным учёта")
continuation = table_sheet("Баланс (2)", [["Капитал и резервы", "1300", D("500000.00")]])
return [form, continuation]
def _normalized_zip(data: bytes) -> bytes:
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
при каждом `save`, заданное в свойствах значение перезаписывает)."""
@ -1219,6 +1482,16 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
"s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx),
"s48_other_system_titles.xlsx": (_s48, to_xlsx),
"s49_balance_and_turnover_by_side.xlsx": (_s49, to_xlsx),
"s50_sap_in_out_glued_side.xlsx": (_s50, to_xlsx),
"s51_untitled_navision.xlsx": (_s51, to_xlsx),
"s52_mixed_book.xlsx": (_s52, to_xlsx),
"s53_balance_by_side.xls": (_s53, to_xls),
"s54_untitled_navision.xlsb": (_s54, to_xlsb),
"s55_not_osv_titles.xlsx": (_s55, to_xlsx),
"s56_almost_osv_tables.xlsx": (_s56, to_xlsx),
"s57_form_mentions_osv.xlsx": (_s57, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -1237,7 +1510,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s36, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.",
description="Синтетические ОСВ s01…s36, s40…s57 (xlsx, xlsb и xls) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)

View File

@ -26,15 +26,19 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s36_and_s40_to_s57_with_two_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
numbers = [*range(1, 37), *range(40, 48)]
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44, ОСВ других систем (#81) —
# s48…s57
numbers = [*range(1, 37), *range(40, 58)]
assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers]
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
assert [name for name in names if name.endswith(".xlsb")] == [
"s13_no_outline_indent.xlsb",
"s54_untitled_navision.xlsb",
]
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -54,6 +54,21 @@ FLAT_FORM_FILES = (
"s46_counterparty_code_blocks.xlsx",
"s47_indent_hierarchy_code_sums.xlsx",
)
# ОСВ других учётных систем (#81): контрольный набор — позитивы s48–s54 (на базе падают), негативы s55–s57 (вид листов
# и результат не меняются)
S48 = "s48_other_system_titles.xlsx"
S49 = "s49_balance_and_turnover_by_side.xlsx"
S50 = "s50_sap_in_out_glued_side.xlsx"
S51 = "s51_untitled_navision.xlsx"
S52 = "s52_mixed_book.xlsx"
S53 = "s53_balance_by_side.xls"
S54 = "s54_untitled_navision.xlsb"
S55 = "s55_not_osv_titles.xlsx"
S56 = "s56_almost_osv_tables.xlsx"
S57 = "s57_form_mentions_osv.xlsx"
OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57)
S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка")
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
@ -86,6 +101,7 @@ EXPECTED_NAMES = [
"s35_currency_slices.xlsx",
"s36_no_data_type_header.xlsx",
*FLAT_FORM_FILES,
*OTHER_SYSTEMS_FILES,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -194,6 +210,17 @@ EXPECTED: dict[str, Expect] = {
"s47_indent_hierarchy_code_sums.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3
),
# #81: названия других систем, остатки, SAP; безымянный лист — без шапки (организация, период, вид данных пусты)
S48: Expect("outline", 2025, "Q1", True, documents=len(S48_SHEETS)),
S49: Expect("outline", 2025, "Q1", True, documents=2),
S50: Expect("outline", 2025, "Q1", True, documents=2),
S51: Expect("outline", None, None, None, data_type=None, organization=""),
S52: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, data_type=None, organization=""),
S53: Expect("indent", 2025, "Q1", True, documents=2),
S54: Expect("indent", None, None, None, data_type=None, organization=""),
S55: Expect("outline", 2025, "Q1", True),
S56: Expect("outline", 2025, "Q1", True),
S57: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, documents=0),
}
@ -810,3 +837,138 @@ def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
assert {row.data_type for row in doc.rows} == {"BU"}
assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
assert outcome.status is ResultStatus.DONE
# --- ОСВ других учётных систем (#81): контрольный набор ---
ALFA_SUMS = (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
BUYER_SUMS = (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None)
TOTAL_NOT_FOUND = "строка «Итого» не найдена"
def sheet_other(outcome: ParseOutcome) -> list[str]:
"""Листы, пропущенные как прочие (`SHEET_OTHER`), по порядку."""
warnings = outcome.metrics.warnings if outcome.metrics is not None else None
return [w.locator or "" for w in warnings or [] if w.code == "SHEET_OTHER"]
def assert_done_with_sums(doc: OsvDocument, text: str, expected: tuple[Decimal | None, ...]) -> None:
"""Документ сошёлся, и у строки `text` шесть сумм по сторонам: перепутанные Дебет/Кредит здесь не пройдут."""
assert sums(by_text(doc)[text]) == expected
assert doc.status == "done", doc.checks
assert doc.rows[-1].group_type == "TOTAL"
def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None:
"""Каждое название ведомости — свой лист ОСВ; «ВСЕГО» и «Всего » — итоговая строка (AURORA-1717, 1732)."""
outcome = parse_named(fixtures_dir, S48)
docs = documents(outcome)
assert [doc.locator for doc in docs] == [f"sheet:{name}" for name in S48_SHEETS]
assert sheet_other(outcome) == []
for doc in docs:
assert_done_with_sums(doc, ALFA, ALFA_SUMS)
totals = {doc.locator: doc.rows[-1].row_value.strip() for doc in docs}
assert totals["sheet:Главная книга"] == "ВСЕГО"
assert totals["sheet:Вторая колонка"] == "Всего"
assert not [text for text in warnings_of(outcome) if TOTAL_NOT_FOUND in text]
def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None:
"""«Остаток на начало/конец», «Оборот по дебету/кредиту», «Остаток по дебету на начало»; преамбула «Остаток на
начало: 0» над таблицей строкой групп не считается."""
outcome = parse_named(fixtures_dir, S49)
first, second = documents(outcome)
assert (first.locator, second.locator) == ("sheet:Остатки", "sheet:Остатки по сторонам")
assert_done_with_sums(first, ALFA, ALFA_SUMS)
assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS)
def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None:
"""SAP «Сальдо вх/исх Дт/Кт» и сторона, приклеенная к группе («ДТВВ»); «ООО Дтвв-Сервис» — контрагент."""
outcome = parse_named(fixtures_dir, S50)
sap, glued = documents(outcome)
assert (sap.locator, glued.locator) == ("sheet:SAP", "sheet:НМЖК")
assert_done_with_sums(sap, ALFA, ALFA_SUMS)
assert_done_with_sums(glued, "ООО Дтвв-Сервис", ALFA_SUMS)
assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY"
def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None:
"""Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак)."""
outcome = parse_named(fixtures_dir, S51)
doc = one_document(outcome)
assert doc.locator == "sheet:ОВ"
assert sheet_other(outcome) == []
assert_done_with_sums(doc, ALFA, ALFA_SUMS)
def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None:
"""Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ,
«Справка» — прочий лист."""
outcome = parse_named(fixtures_dir, S52)
assert [(d.document_type, d.locator) for d in outcome.documents] == [
("OSV", "sheet:ОВ"),
("BALANCE", "sheet:Бухгалтерский баланс"),
]
assert sheet_other(outcome) == ["sheet:Справка"]
assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS)
def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None:
"""s49 в xls (без объединений): те же колонки и суммы."""
outcome = parse_named(fixtures_dir, S53)
first, second = documents(outcome)
assert_done_with_sums(first, ALFA, ALFA_SUMS)
assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS)
def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None:
"""s51 в xlsb: ОСВ по запасному признаку, те же суммы."""
outcome = parse_named(fixtures_dir, S54)
assert sheet_other(outcome) == []
assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS)
def test_neg_s55_titles_like_osv_are_other(fixtures_dir: Path) -> None:
"""Название не с начала ячейки, «Оборотный капитал», 1С «Обороты счета» с корр. счетами между «Оборот Дт» и
«Оборот Кт» (R-1, R-11) — прочие листы; ОСВ одна."""
outcome = parse_named(fixtures_dir, S55)
# «Входные данные» (перечень документов шаблона банка, R-13) пакет форм относит к расшифровке — как реальный лист
# «Входные данные_ИП» на базе; главное — не ОСВ и книга не падает
assert [(d.document_type, d.locator) for d in outcome.documents] == [
("OSV", "sheet:ОСВ 60"),
("DISCLOSURE", "sheet:Входные данные"),
]
assert sheet_other(outcome) == ["sheet:Справка", "sheet:Капитал", "sheet:Обороты счета"]
def test_neg_s56_almost_osv_tables_are_other(fixtures_dir: Path) -> None:
"""Расшифровка с остатками, таблица без «начала», свёрнутое начало, заголовок ниже 30-й строки — прочие листы."""
outcome = parse_named(fixtures_dir, S56)
assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"]
assert sheet_other(outcome) == [
"sheet:Расшифровка",
"sheet:Без начала",
"sheet:Свёрнутое начало",
"sheet:Ниже 30",
"sheet:Преамбула",
]
def test_neg_s57_form_with_osv_words_stays_form(fixtures_dir: Path) -> None:
"""Форма ББ с примечанием «см. оборотную ведомость» и слабым продолжением — форма из двух листов, ОСВ нет."""
outcome = parse_named(fixtures_dir, S57)
assert [(d.document_type, d.locator) for d in outcome.documents] == [
("BALANCE", "sheet:Бухгалтерский баланс,Баланс (2)")
]
assert sheet_other(outcome) == []

View File

@ -146,6 +146,61 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) ->
assert [s.name for s in plan.other] == ["Примечания", "2400"]
class _CountingTable:
"""`osv_by_table` для `plan_sheets`: лист с именем из `osv` — ОСВ по таблице; запоминает, для каких листов звали."""
def __init__(self, *osv: str) -> None:
self.osv = set(osv)
self.asked: list[str] = []
def __call__(self, sheet: Sheet) -> bool:
self.asked.append(sheet.name)
return sheet.name in self.osv
def _no_osv(sheet: Sheet) -> bool:
return False
def test_plan_sheets_table_fallback_only_for_other(rules: StatementRules) -> None:
"""#81, D1: запасной признак не спрашивается у листа формы; лист с таблицей ОСВ — ОСВ, «Справка» — прочее."""
form = _form("Бухгалтерский баланс", BALANCE_CODES, "ББ")
table = sheet_from_rows([["НачалоДт"]], name="ОВ")
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
by_table = _CountingTable("ОВ")
plan = plan_sheets([form, table, reference], _no_osv, rules, NO_DEADLINE, osv_by_table=by_table)
assert plan.osv == (table,)
assert [(s.name, k) for s, k in plan.statements] == [("ББ", STATEMENT)]
assert plan.other == (reference,)
assert by_table.asked == ["ОВ", "Справка"]
def test_plan_sheets_table_fallback_before_weak_continuation(rules: StatementRules) -> None:
"""R-12: лист с таблицей ОСВ после формы — ОСВ, а не слабое продолжение; без таблицы — продолжение, как было
(Review Focus 4)."""
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400")
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable())
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
assert plan.osv == ()
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable("2400"))
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT)]
assert plan.osv == (weak,)
def test_plan_sheets_without_table_fallback_is_unchanged(rules: StatementRules) -> None:
"""Без `osv_by_table` — прежнее поведение: «прочее» остаётся прочим."""
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
plan = plan_sheets([reference], _no_osv, rules, NO_DEADLINE)
assert (plan.osv, plan.statements, plan.other) == ((), (), (reference,))
def test_is_osv_sheet_by_marker() -> None:
rules = load_rules()
osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]])

View File

@ -17,6 +17,7 @@ from src.parser.detect import (
classify_sheet,
find_table_header,
first_data_row,
has_osv_table,
inn_from_cell,
is_header_repeat,
)
@ -110,6 +111,86 @@ def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None:
assert classify_sheet(sheet, rules) is SheetKind.OTHER
# --- Запасной признак листа ОСВ по заголовку таблицы (#81, design D1, R-2, R-3, R-11) ---
NAVISION_HEADER = ["ФинСчет", "ИсточникНазвание", "НачалоДт", "НачалоКт", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"]
NAVISION_ROW = at({1: "60", 3: 10, 5: 5, 7: 15})
def test_has_osv_table_navision_split_sides(rules: Rules) -> None:
"""Navision без названия: все три группы, у каждой Дт и Кт в ячейках группы — ОСВ."""
assert has_osv_table(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is True
def test_has_osv_table_with_subheaders(rules: Rules) -> None:
"""Обычный заголовок: группы над «Дебет | Кредит»."""
sheet = sheet_from_rows(
[
["Счет", OPENING, None, TURNOVER, None, CLOSING],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
at({1: "60", 2: 10, 7: 10}),
]
)
assert has_osv_table(sheet, rules) is True
def test_has_osv_table_requires_opening(rules: Rules) -> None:
header = ["Счет", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 5})]), rules) is False
def test_has_osv_table_rejects_collapsed(rules: Rules) -> None:
header = ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 3: 10})]), rules) is False
def test_has_osv_table_rejects_disclosure(rules: Rules) -> None:
"""Расшифровка с остатками: «Поступило/Выбыло» — не обороты (Review Focus 1)."""
header = ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"]
assert has_osv_table(sheet_from_rows([header, ["Материалы", 100, 50, 30, 120]]), rules) is False
def test_has_osv_table_rejects_columns_between_sides(rules: Rules) -> None:
"""1С «Обороты счета»: между «Оборот Дт» и «Оборот Кт» — колонки корр. счетов (R-1, R-11)."""
header = ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"]
header += ["Конечное сальдо Дт", "Конечное сальдо Кт"]
assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 4: 50})]), rules) is False
@pytest.mark.parametrize(("blank_rows", "expected"), [(29, True), (30, False)])
def test_has_osv_table_only_first_rows(rules: Rules, blank_rows: int, expected: bool) -> None:
"""Строка групп ищется в первых `SHEET_MARKER_ROWS` (30) строках (R-2): 30-я — да, 31-я — нет."""
sheet = sheet_from_rows([*([[]] * blank_rows), NAVISION_HEADER, NAVISION_ROW])
assert has_osv_table(sheet, rules) is expected
@pytest.mark.parametrize(
"preamble",
[
["Обороты за период: 50", "Остаток на конец: 150"],
["Остаток на начало: 100", "Обороты: 50", "Остаток на конец: 150"],
],
)
def test_has_osv_table_checks_the_row_find_table_header_takes(rules: Rules, preamble: list[object]) -> None:
"""Финальное ревью, Important-1: преамбула с «оборотами» над полной шапкой — строка, которую возьмёт разбор
(`find_table_header`); она не годится, значит, лист не ОСВ (иначе разбор листа упадёт и уронит книгу)."""
sheet = sheet_from_rows([preamble, NAVISION_HEADER, NAVISION_ROW])
with pytest.raises(InvalidFormatError):
find_table_header(sheet, rules)
assert has_osv_table(sheet, rules) is False
def test_classify_sheet_falls_back_to_table(rules: Rules) -> None:
assert classify_sheet(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is SheetKind.OSV
# --- find_table_header: варианты заголовка ---
@ -282,6 +363,25 @@ def test_one_row_header_with_sides_in_group_cells(rules: Rules) -> None:
assert [first_data_row(sheet, header)] == [3]
def test_side_without_word_boundary_only_in_group_cell(rules: Rules) -> None:
"""#81, SAP НМЖК: сторона приклеена к названию группы («Сальдо НачальноеДТВВ», «ОборотыДтВВ»); «Дтвв» в тексте
контрагента ниже — не сторона и колонок не сдвигает."""
sheet = sheet_from_rows(
[
[TITLE],
["Счет", "Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ", "ОборотыДтВВ", "ОборотыКтВВ"]
+ ["Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"],
[L(0), *at({1: "60", 2: 10, 7: 10})],
[L(1), *at({1: "ООО Дтвв-Сервис", 2: 10, 7: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
assert (header.first_row, header.last_row) == (2, 2)
def test_unmerged_group_titles_take_subheaders_up_to_the_next_group(rules: Rules) -> None:
sheet = sheet_from_rows(
[

View File

@ -330,10 +330,16 @@ def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None:
def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None:
# «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно
for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"):
for text in ("Нач. остаток", "Начальный остаток"):
assert not any(p.search(text) for p in rules.headers.groups["opening"]), text
@pytest.mark.parametrize("text", ["остаток на начало периода", "остаток по дебету на начало", "сальдо вх дт"])
def test_other_systems_opening_synonyms(rules: Rules, text: str) -> None:
"""#81, AURORA-1719: написания группы «начало» выгрузок КТЗ и SAP (текст — нормализованный)."""
assert any(p.search(text) for p in rules.headers.groups["opening"]), text
@pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."])
def test_debit_synonyms(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.debit)

View File

@ -0,0 +1,292 @@
# ОСВ других учётных систем — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** листы ОСВ SAP, Navision, выгрузок КТЗ и отчётов 1С с другим названием распознаются как ОСВ: признаки листа по
названию, запасной признак по полному заголовку таблицы, написания групп и сторон, итог «ВСЕГО».
**Architecture:** новые написания — только записи `api/rules/osv_headers.yaml` + синтетика на каждую (R-22). Код меняется
в одном месте: запасной признак `has_osv_table` в `detect.py`, который `plan_sheets` вызывает для листа, попавшего бы в
«прочее». Регрессия — прогон банка первички до и после, сравнение по документам.
**Tech Stack:** Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec.
**Spec:** `tabular-parser/openspec/changes/osv-other-systems/` (proposal.md, design.md, specs/osv-parsing/spec.md).
Правило — `docs/context/05-domain-rules.md` §1.1–§1.2 (меняется в Task 5). Issue RSHB/arm-tabular-parser#81. Jira
AURORA-1717, AURORA-1719, AURORA-1732. Ветка `feat/81-osv-other-systems` в трёх worktree: `.wt/osv-dict/tabular-parser`,
`.wt/osv-dict/fixtures`, `.wt/osv-dict/docs` (корень `W = /Users/andrey.epifancev/projects/awp-ca/.wt/osv-dict`).
## Global Constraints
- Никаких `if` под файл, организацию, систему-источник или имя листа: новое написание = запись YAML + синтетика (CLAUDE.md
tabular-parser, «Правила расширения»).
- Каждая новая запись словаря срабатывает хотя бы на одной синтетике (`tests/fixtures/test_rules_coverage.py`, R-22).
- Паттерны пишутся по нормализованному тексту: нижний регистр, «ё» → «е», дефисы/тире/переносы/nbsp → пробел, пробелы
схлопнуты (шапка `osv_headers.yaml`).
- Новые `sheet_markers` — с якорем `^` (R-5); якорь снимается для названия, только если файлы списка 1717 без этого не
находятся (записать в design.md, раздел решений).
- Запасной признак: только лист, который `plan_sheets` иначе отнёс бы к «прочее»; строка групп в первых
`SHEET_MARKER_ROWS` (30) строках; все три группы; у каждой — и Дебет, и Кредит; свёрнутое сальдо не годится (D1, R-2,
R-3, R-6).
- Пакет `api/arm_statements/` не трогать. Контракт не меняется.
- Код — только Edit/Write, не sed/скрипты через Bash (правило владельца 18.10.2026). cwd оболочки держать в
`W/tabular-parser`; fixtures и docs править по абсолютным путям, git — `git -C <абсолютный путь>`.
- Файлы банка первички (`/Users/andrey.epifancev/projects/awp-ca/doc-bank/`) — только чтение, в git не кладутся.
- Golden правится только `make golden-update` с просмотром diff, отдельным коммитом в fixtures.
- Комментарии, сообщения коммитов — по-русски; коммиты заканчиваются строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Расшифровка ББ, которую пакет не узнал как форму («Остаток на начало | Поступило | Выбыло | Остаток на конец»),
не становится ОСВ: в ней нет оборотов → `has_osv_table` ложь (тест в Task 3).
2. Преамбула шапки «Остаток на начало: … | Остаток на конец: …» над таблицей не принимается за строку групп: в ней нет
«оборотов» (тест в Task 2 на синтетике s49 — преамбула выше таблицы).
3. Сторона без границы слова («ДТВВ») не ловится в ячейках, где нет группы: «Дт» внутри текста контрагента
(«ООО Дтвв-Сервис») не даёт стороны (тест в Task 2).
4. Лист «прочее» после листа формы, который сейчас идёт как слабое продолжение (`is_weak_continuation`), остаётся
продолжением — запасной признак проверяется только в ветке «прочее» (тест в Task 3).
5. Книга с формами и без единого листа ОСВ: на листах форм `has_osv_table` не вызывается (тест в Task 3 — счётчик
вызовов на книге s14).
---
### Task 1: Базовая линия: банк первички, образцы, контрольный набор синтетики
**Files:**
- Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/regress.py`
- Create: `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/headers.md`
- Create (вывод): `.../osv-other-systems-regress/base.csv`, `control-base.md`
- Modify: `api/tests/synthetic/generate.py` (`OsvSpec`, `_header_v1`, `_header_rows`, `build_osv`, `table_sheet`,
`_s48…_s57`, `FILE_NAMES`, docstring модуля), `api/tests/synthetic/test_synthetic_files.py`
- Create (генерация): `W/fixtures/osv/synthetic/s48…s57`; Modify: `W/fixtures/osv/synthetic/README.md`
**Interfaces:**
- Produces: `regress.py run <out.csv>` и `regress.py diff <base.csv> <new.csv>`; `headers.md` — точные тексты шапок и
заголовков реальных образцов для синтетики Task 2–3.
- [ ] **Step 1: venv.** `cd W/tabular-parser && make venv` → `api/.venv/bin/python` есть; `make check` зелёный на
исходной ветке (golden и покрытие читают `../fixtures` — это worktree fixtures на той же базе `f1f7f83`).
- [ ] **Step 2: `regress.py run`.** Обходит `doc-bank/index/index.csv`, строки с `route == "парсер"`; разбирает файл
в процессе тем же путём, что `src.parser.cli` (`get_parser(Settings())`, тип файла — по расширению), запуск из
`W/tabular-parser/api` с `ENV_FILE=.env.test` и `PYTHONPATH=.`. Пишет CSV, по строке на документ (на файл без
документов — одна строка): `sha256, name, locator, kind (osv|BALANCE|INCOME|…|none), status, error_code,
sheet_other_count`. Исключение парсера — строка с `error_code = EXCEPTION:<класс>`, прогон не прерывается.
- [ ] **Step 3: `regress.py diff`.** Сопоставляет по `(sha256, locator)`; печатает таблицу переходов `(kind, status) →
(kind, status)` с числом файлов и список ухудшений по D3: `done → needs_check|error`, `needs_check → error`, любой
`kind` формы → `osv`, `none/other → osv`. Переходы в `osv` помечает «проверить вручную» с именем файла.
- [ ] **Step 4: базовый прогон.** `regress.py run base.csv` на текущем HEAD ветки (код = main `a8c9605`). Ожидаемо по
индексу: ~1681 файл; среди ошибок — `INVALID_DOCUMENT` у файлов из списка 1717.
- [ ] **Step 5: образцы заголовков.** Из `doc-bank` найти по `index.csv` (`parse_bug` содержит `AURORA-1717`/`1719`)
по одному файлу на каждое написание из таблиц AURORA-1717 и 1719 (+ «ВСЕГО» из 1732) и выписать в `headers.md`
дословно: строку названия, строку(и) групп и подзаголовков (ячейка за ячейкой), подпись итога. Особо: как у Navision
подписан оборот («ОборотДт»? «Оборот Дт»?), как у SAP НМЖК — «Оборот» со стороной.
- [ ] **Step 6: генератор.** В `api/tests/synthetic/generate.py` поля `OsvSpec` (см. Interfaces ниже) и их поддержка в
`_header_v1`/`_header_rows`/`build_osv`; существующие s01–s47 побайтно не меняются (`test_generate.py` и повторная
генерация в `W/fixtures/osv/synthetic` — `git -C W/fixtures status` чистый по старым файлам).
- [ ] **Step 7: контрольный набор синтетики** — файлы и тесты по таблице ниже, тексты подписей — из `headers.md`, где
есть реальный образец. Набор пишется до правок словаря и кода и должен уметь опровергнуть успех: позитивы проверяют
не только «лист — ОСВ», но и точные суммы по сторонам (перепутанные Дебет/Кредит ломают тождество строки и суммы);
негативы проверяют, что вид листа и результат не изменились.
Позитивы (на базе падают, после Task 2–3 проходят):
| Файл | Что в нём | Тест и проверки |
|---|---|---|
| `s48_other_system_titles.xlsx` | листы `build_osv` с `title`: «Оборотная ведомость по счету 60 за 1 квартал 2025 г.», «ОСВ по сч. 60 …», «Оборотный баланс …», «Фин. Обороты Листок» (+ строка «Период: …»), «Отчет по Главной книге …»; ещё два листа — название во второй колонке и «ОБОРОТНАЯ\nВЕДОМОСТЬ по счету 60» (верхний регистр, перенос); у одного листа `total_text="ВСЕГО"`, у другого `"Всего "` | `test_s48_other_system_titles_are_osv`: 7 `osvDocument` по листам, `SHEET_OTHER` нет; у листов «ВСЕГО» итог — `TOTAL`, предупреждения «строка «Итого» не найдена» нет; суммы `base_tree()` |
| `s49_balance_and_turnover_by_side.xlsx` | «Остаток на начало периода» / «Остаток на конец периода» с «Дебет/Кредит» под ними, `split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}`; над таблицей преамбула «Остаток на начало: 0 \| Остаток на конец: 0» (Review Focus 2) | шесть колонок, суммы дерева, статус `done` |
| `s50_sap_in_out_glued_side.xlsx` | лист SAP «Сальдо вх Дт \| Сальдо вх Кт \| Оборот Дт \| Оборот Кт \| Сальдо исх Дт \| Сальдо исх Кт»; лист НМЖК «Сальдо НачальноеДТВВ \| … \| Сальдо КонечноеКТВВ»; контрагент «ООО Дтвв-Сервис» (Review Focus 3) | оба листа — шесть колонок с верными сторонами, суммы дерева, «ООО Дтвв-Сервис» — строка контрагента, `done` |
| `s51_untitled_navision.xlsx` | `untitled=True`, три `split_groups` «НачалоДт/НачалоКт», «Оборот…», «КонецДт/КонецКт» (по `headers.md`), первая колонка «ФинСчет» | один `osvDocument`, `SHEET_OTHER` нет, суммы дерева; статус — фактический (без шапки ожидаемо `needs_check` по периоду/организации), зафиксировать в `EXPECTED` с комментарием |
| `s52_mixed_book.xlsx` | `statement_sheet()` (форма ББ) → лист как в s51 → `reference_sheet()` | документ формы тот же, что у книги без Navision-листа; Navision — `osvDocument`; «Справка» — `SHEET_OTHER` |
| `s53_balance_by_side.xls`, `s54_untitled_navision.xlsb` | s49 через `to_xls`, s51 через `to_xlsb` | те же проверки, что у s49 / s51 (читатели xls и xlsb) |
Негативы (проходят и на базе, и после — иначе успех опровергнут):
| Файл | Что в нём | Тест и проверки |
|---|---|---|
| `s55_not_osv_titles.xlsx` | обычная ОСВ 1С (чтобы книга не была ошибкой) + листы с таблицей без групп ОСВ: «Справка: оборотная ведомость прилагается», «Оборотный капитал и его структура», отчёт 1С «Обороты счета 60» с колонками корр. счетов внутри «Оборот Дт/Кт» (R-1) | 1 `osvDocument`; три листа — `SHEET_OTHER` |
| `s56_almost_osv_tables.xlsx` | обычная ОСВ 1С + безымянные листы: (а) «Наименование \| Остаток на начало \| Поступило \| Выбыло \| Остаток на конец»; (б) «Оборот Дт \| Оборот Кт \| Сальдо на конец Дт \| Кт» без начала; (в) свёрнутое начало «Сальдо на начало» одной колонкой + обороты и конец с Дт/Кт; (г) полный заголовок Navision с 31-й строки | 1 `osvDocument`; (а)–(г) — `SHEET_OTHER` (D1, R-2, R-3, Review Focus 1) |
| `s57_form_mentions_osv.xlsx` | форма ББ (`statement_sheet()`) с ячейкой примечания «см. оборотную ведомость по сч. 60» и строкой «Остаток на начало периода» в тексте пояснения; следом лист слабого продолжения формы (1–2 кода, как в существующих тестах `is_weak_continuation`) | документы формы те же, что на базе (вид, `locator`, число строк); `osvDocument` нет; продолжение присоединено (Review Focus 4) |
`EXPECTED`, список файлов, `FILE_NAMES` — для всех. Негативам — отдельный тест-маркер в имени (`test_neg_…`), чтобы
их прогон читался отдельно.
- [ ] **Step 8: прогон набора на базе.** Сгенерировать в `W/fixtures/osv/synthetic`, `make golden-update` (снимки
всех s48–s57; у негативов это регрессия базового поведения, снимки позитивов фиксируют ошибку базы и обновятся в
Task 2–3), затем
`.venv/bin/pytest tests/synthetic -k "s48 or s49 or s50 or s51 or s52 or s53 or s54 or s55 or s56 or s57" -rA`.
Ожидаемо: все `test_neg_…` — PASS, все позитивы — FAIL. Иначе стоп: позитив, проходящий на базе, ничего не
доказывает (переписать), негатив, падающий на базе, неверно описывает текущее поведение (разобрать). Итог — таблица
«тест → результат на базе» в `admin/osv-other-systems-regress/control-base.md`.
- [ ] **Step 9: коммиты.** tabular-parser: генератор и тесты контрольного набора — «Контрольный набор синтетики ОСВ
других систем (#81)» (позитивы падают — допустимо в ветке до Task 3: пометить их `pytest.mark.xfail(strict=True,
reason="tp#81: до Task 2–3")`, снимать `xfail` по мере реализации). fixtures: файлы s48–s57 и README — «Синтетика
s48–s57 под tp#81»; снимки негативов — отдельным коммитом.
**Interfaces (генератор, Step 6):**
- Поля `OsvSpec`:
- `title: str | None = None` — строка названия вместо «Оборотно-сальдовая ведомость по счету {account} за {period}»
(`None` — как сейчас);
- `title_col: int = 1` — колонка строки названия;
- `untitled: bool = False` — без строк организации, названия, параметров: заголовок таблицы с первой строки;
- `top_padding: int = 0` — пустых строк над заголовком таблицы при `untitled` (для s56 (г): 30);
- `split_groups: dict[str, tuple[str, str]] = {}` — группы, у которых вместо слитого названия + «Дебет/Кредит» две
ячейки в строке групп со своими текстами (сторона в ячейке группы); если разделены все три группы, строки
подзаголовков нет;
- `total_text: str = TOTAL_TEXT`.
- Листы-негативы без дерева ОСВ — функцией по образцу `reference_sheet`/`_flat_sheet`: `table_sheet(name: str,
rows: Sequence[Sequence[str | D | None]], *, start_row: int = 1) -> SheetSpec`.
---
### Task 2: Словари (1717 названия, 1719 группы и стороны, 1732 «ВСЕГО»)
**Files:**
- Modify: `api/rules/osv_headers.yaml` (`sheet_markers`, `groups.opening`, `groups.closing`, `groups.turnover` — если
нужно для «ОборотДт», `debit`, `credit`, `total_row_exact`)
- Modify: `api/tests/synthetic/test_synthetic_files.py` (снять `xfail` у s48–s50, s53)
- Modify: `api/tests/unit/parser/test_detect.py`
**Interfaces:**
- Consumes: контрольный набор и `headers.md` из Task 1.
- [ ] **Step 1: падающий unit-тест.** `test_detect.py::test_side_without_word_boundary_only_in_group_cell` — сетка
`sheet_from_rows`: заголовок «Сальдо НачальноеДТВВ | …», ниже строка с текстом «Дтвв» в колонке сумм →
`find_table_header` берёт колонки только из строки групп. Снять `xfail` у тестов s48, s49, s50, s53.
- [ ] **Step 2: тесты падают.** `.venv/bin/pytest tests/synthetic tests/unit/parser/test_detect.py -k "s48 or s49 or
s50 or s53 or word_boundary"` — FAIL (`INVALID_DOCUMENT`/`INVALID_FORMAT`).
- [ ] **Step 3: записи словаря.** Ориентир (уточнить по `headers.md`; ловушки: `\w*` жадно съест приклеенную сторону —
«сальдо\s+начальн\w*» оставит пустой остаток вместо «дтвв»; `\bоборот(ы)?\b` не матчит «оборотдт»):
- `sheet_markers`: `^оборотн\w*\s+ведомост`, `^осв\b`, `^оборотн\w*\s+баланс`, `^фин\.?\s*обороты`,
`^отчет\s+по\s+главной\s+книге`;
- `groups.opening`: `остат\w*\s+на\s+начало`, `сальдо\s+начальное`, `сальдо\s+вх\b`, `^начало(?=\s*(дт|кт))`;
- `groups.closing`: `остат\w*\s+на\s+конец`, `сальдо\s+конечное`, `сальдо\s+исх\b`, `^конец(?=\s*(дт|кт))`;
- `groups.turnover` (если `headers.md` подтверждает «ОборотДт» без пробела): `^оборот(?=\s*(дт|кт))`;
- `debit`: `^по\s+дебету\b`, `^дт\s*вв\b`; `credit`: `^по\s+кредиту\b`, `^кт\s*вв\b`;
- `total_row_exact`: `^всего$` (комментарий: ГК КТЗ, сч. 009, AURORA-1732).
Каждой записи — комментарий с источником (AURORA-…, система). Если сторону без границы слова нельзя выразить
записью (остаток ячейки не начинается со стороны), правка `_side`/`_group_cells` общая, без упоминания системы
(design D2), и тест на неё.
- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` контрольного набора → PASS (негатив упал —
запись словаря слишком широкая: сузить, не ослаблять тест). `make golden-update`: меняются снимки s48–s50, s53; снимки
негативов s55–s57 и всех старых файлов не изменились (изменились — стоп, разобрать). `make check` → PASS (покрытие
словарей видит новые записи).
- [ ] **Step 5: коммиты.** tabular-parser: `rules/osv_headers.yaml`, `tests/synthetic/test_synthetic_files.py`,
`tests/unit/parser/test_detect.py` — «ОСВ: названия ведомостей, группы и стороны других систем, «ВСЕГО» (#81)».
fixtures (`git -C W/fixtures`): снимки s48–s50, s53 — «Golden синтетики s48–s50, s53 под tp#81».
---
### Task 3: Запасной признак листа ОСВ по заголовку таблицы
**Files:**
- Modify: `api/src/parser/detect.py` (новая `has_osv_table`, `classify_sheet`, `__all__`)
- Modify: `api/src/parser/statements/adapter.py:56-83` (`plan_sheets`)
- Modify: `api/src/parser/book.py:146`
- Modify: `api/tests/synthetic/test_synthetic_files.py` (снять `xfail` у s51, s52, s54)
- Test: `api/tests/unit/parser/test_detect.py`, `api/tests/unit/parser/statements/test_adapter.py` (тесты `plan_sheets`), `api/tests/unit/parser/test_book_parser.py`
**Interfaces:**
- Consumes: контрольный набор из Task 1 (s51, s52, s54 — позитивы; s55–s57 — негативы).
- Produces:
- `detect.has_osv_table(sheet: Sheet, rules: Rules) -> bool` — в первых `SHEET_MARKER_ROWS` строках есть строка, где
`_group_cells` даёт все три группы, и `_columns_of_group` для каждой возвращает `debit` и `credit` (не `collapsed`);
`InvalidFormatError` из `_columns_of_group` → `False`. Без дедлайна (≤ 30 строк).
- `plan_sheets(sheets, is_osv, rules, deadline, osv_by_table: Callable[[Sheet], bool] | None = None) -> SheetPlan` —
в ветке `else` (лист пошёл бы в `other`): если `osv_by_table` задан и истинен — лист в `osv`, `last_type = None`;
иначе `other`, как сейчас.
- `classify_sheet`: если пакет дал «прочее» и `has_osv_table` — `SheetKind.OSV` (согласованность с `plan_sheets`).
- `book.py`: `plan_sheets(..., osv_by_table=lambda sheet: has_osv_table(sheet, self._rules))`.
- [ ] **Step 1: падающие тесты.**
- `test_detect.py`:
- `test_has_osv_table_navision_split_sides` — `sheet_from_rows`: первая строка «ФинСчет | НачалоДт | НачалоКт |
ОборотДт | ОборотКт | КонецДт | КонецКт» (тексты — из `headers.md`), строки данных → `True`;
- `test_has_osv_table_requires_opening` — то же без «НачалоДт/Кт» → `False`;
- `test_has_osv_table_rejects_collapsed` — «Сальдо на начало | Обороты (Дебет/Кредит) | Сальдо на конец» с одной
колонкой у начала → `False`;
- `test_has_osv_table_rejects_disclosure` — «Наименование | Остаток на начало | Поступило | Выбыло | Остаток на
конец» → `False` (Review Focus 1);
- `test_has_osv_table_only_first_rows` — полный заголовок в строке 31 → `False`;
- `test_classify_sheet_falls_back_to_table` — Navision-сетка → `SheetKind.OSV`.
- тест `plan_sheets`:
- `test_plan_sheets_table_fallback_only_for_other` — лист формы, затем Navision-лист, затем «Справка» → `osv`
содержит только Navision-лист, «Справка» в `other`; `osv_by_table` вызван только для двух последних листов
(счётчик вызовов, Review Focus 5);
- `test_plan_sheets_weak_continuation_not_taken` — лист формы, затем лист из 1–2 кодов (слабое продолжение,
образец — существующие тесты `is_weak_continuation`) → остаётся `STATEMENT_CONTINUATION`, `osv_by_table` не
вызван (Review Focus 4).
- снять `xfail` у тестов s51, s52, s54 контрольного набора.
- `test_book_parser.py::test_statement_book_sheets_unchanged_by_fallback` — книга s14 (формы + ОСВ): виды листов те же,
что без запасного признака.
- [ ] **Step 2: тесты падают.** `cd api && .venv/bin/pytest tests/unit/parser tests/synthetic -k "osv_table or
plan_sheets or s51 or s52 or s54 or fallback"` → FAIL (`ImportError: has_osv_table`, `INVALID_DOCUMENT`).
- [ ] **Step 3: реализация** по Interfaces. `has_osv_table` переиспользует `_group_cells` и `_columns_of_group` (R-6),
отдельного разбора сторон нет.
- [ ] **Step 4: тесты зелёные.** Тот же `pytest` → PASS; все `test_neg_…` → PASS; `make golden-update` — в diff
меняются только снимки s51, s52, s54, снимки негативов s55–s57 и старых файлов не изменились; `make check` → PASS.
- [ ] **Step 5: коммиты.** tabular-parser: «ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)».
fixtures: снимки s51, s52, s54 — отдельным коммитом.
---
### Task 4: Golden, регрессия банка, check-full
**Files:**
- Modify (генерация): `W/fixtures/golden/expected_osv_parse/*.json`
- Create (вывод): `/Users/andrey.epifancev/projects/awp-ca/admin/osv-other-systems-regress/new.csv`, `report.md`
- [ ] **Step 1: golden.** `make golden-check` → код 0; `git -C W/fixtures diff --stat f1f7f83 -- golden/` — только
добавленные снимки s48–s57, ни одного изменённого (изменённый — стоп, разобрать с объяснением в design.md).
Контрольный набор целиком: `.venv/bin/pytest tests/synthetic -k "s48 or s49 or s50 or s51 or s52 or s53 or s54 or
s55 or s56 or s57" -rA` → все PASS, `xfail` не осталось; таблица «тест → база → ветка» дописывается в
`control-base.md`.
- [ ] **Step 2: прогон ветки.** `regress.py run new.csv`; `regress.py diff base.csv new.csv > report.md`.
- [ ] **Step 3: разбор.** Каждое ухудшение и каждый переход в `osv` из формы/«прочее» — открыть файл, решить: ведомость
(ожидаемо: «Оборотный баланс» 1718 и файлы списков 1717/1719) или ложное срабатывание. Ложное — правка записи словаря
или якоря, повтор Task 2 Step 4 и этого шага. Не устраняется — стоп, вопрос владельцу с файлом и тем, что видно.
В `report.md` — таблица переходов, список ручных проверок с вердиктом, файлы списков 1717/1719, оставшиеся с
ошибкой, с причиной (1720–1723, «Обороты счета», безымянный лист, ушедший в форму).
- [ ] **Step 4: `make check-full`** (поднимает compose для интеграционных тестов) → PASS. Также
`.venv/bin/pytest tests/fixtures/test_osv_dkb.py` → PASS.
---
### Task 5: Правило 05-domain-rules, документация сервиса
**Files:**
- Modify: `W/docs/context/05-domain-rules.md` §1.1 (поиск листа), §1.2 (написания групп и сторон); строка «Итого» —
где перечислены итоговые подписи (§1.3 или §1.4)
- Modify: `W/tabular-parser/README.md` (раздел «Парсер ОСВ»: признаки листа, запасной признак), `W/tabular-parser/CLAUDE.md`
(перечень синтетики s48–s57 в абзаце golden), docstring `generate.py` (s48–s57)
- Modify: `W/tabular-parser/openspec/changes/osv-other-systems/design.md` — решения, принятые по ходу (R-9…), и
сводка регрессии из `report.md`
- [ ] **Step 1: §1.1** по design D4: признак — название ведомости по `sheet_markers` (перечислить написания) или,
если лист не опознан как форма или продолжение, полный заголовок таблицы ОСВ в первых 30 строках: три группы, у каждой
Дебет и Кредит, без свёрнутого сальдо. §1.2 — написания групп и сторон из Task 2 (сторона в ячейке группы, «по
дебету», приклеенная «ДТВВ»). «ВСЕГО» — рядом с «ИТОГ».
- [ ] **Step 2: проверки.** `openspec validate osv-other-systems --strict` → valid; `make check` → PASS (docstring и
README не ломают линтеры).
- [ ] **Step 3: коммиты.** docs (`git -C W/docs`): «05 §1.1–§1.2: ОСВ других учётных систем (tp#81)»; tabular-parser:
«Документация: признаки листа ОСВ, синтетика s48–s57 (#81)».

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-28

View File

@ -0,0 +1,147 @@
## Context
Классификация листа (`detect.classify_sheet`): ОСВ, если в первых `SHEET_MARKER_ROWS` (30) строках есть ячейка,
совпадающая с `sheet_markers`; иначе вид листа определяет пакет `arm_statements` (форма / продолжение / прочее).
`sheet_markers` сейчас один: `оборотно\s*сальдовая\s+ведомость`. Заголовок таблицы (`_find_group_row`) — первая строка,
где есть группы «обороты» и «сальдо на конец». Сторона ищется в начале текста ячейки (`^(дебет|дт|д т)\b`) — в
отдельной ячейке под группой либо в остатке ячейки группы после совпадения (`_group_cells`: «Сальдо на начало Дт»).
Файлы — банк первичных документов `awp-ca/doc-bank/` (индекс `index/index.csv`, колонки `parse_status`, `parse_error`,
`parse_bug`, `sha256`; в git не кладутся). Списки затронутых файлов с ГК, ЮЛ и sha256 — вложения `список_файлов.csv`
к AURORA-1717 и AURORA-1719. Образцы во вложениях задач:
| Задача | Файл | Что показывает |
|---|---|---|
| 1717, 1719 | «ОСВ по внб счету 009 за 9 мес 2025.xlsx» (Промлит) | «Оборотная ведомость», «Остаток на начало/конец» |
| 1717, 1719 | «ОСВ 66 67 01-06 2025.xlsx» (Виола) | «Отчет по Главной книге», SAP «Сальдо вх Дт / Сальдо исх Кт» |
| 1717 | «67 9 мес. 25.xlsx» (Айсберри) | Navision «Фин. Обороты Листок», «НачалоДт / КонецКт» |
| 1719 | «ОСВсч62_3кв2025.xls» (НМЖК) | SAP «Сальдо НачальноеДТВВ / КонечноеКТВВ» |
| 1732 | «ОСВ по внб сч 009 за 3 кв 2025.xlsx» (КТЗ) | итог «ВСЕГО» |
Эксперимент автора задач на копии словарей (20.10.2026, синонимы 1717 + 1719 без «ДТВВ» и без запасного признака): из
129 файлов 1717 — 43 «Готово», 43 «Требует проверки», 43 упираются в группы и иерархию; из 159 файлов 1719 — 53
«Готово», 58 «Требует проверки», 48 — другое (AURORA-1720, 1721, 1723). Остальные ОСВ банка статусы не ухудшили.
## Goals / Non-Goals
**Goals:**
- Листы с перечисленными названиями и листы без названия с полным заголовком таблицы ОСВ распознаются как ОСВ.
- Перечисленные написания групп и сторон находятся; заголовок таблицы этих файлов не падает с `INVALID_FORMAT`
«нет групп сумм».
- «ВСЕГО» — итоговая строка листа.
- Ни один документ банка первички и ни одна фикстура не ухудшает результат (см. «Регрессия»).
**Non-Goals:**
- Довести все затронутые файлы до «Готово»: дальше их держат иерархия (AURORA-1721, 1730), одинаковые подписи начала и
конца (AURORA-1720), обороты без подзаголовков в варианте НОВО-МАР и SAP со стороной перед группой (AURORA-1722),
ведомости ОС/ТМЦ (AURORA-1723, 1731). Эти файлы могут остаться `needs_check` или `INVALID_FORMAT` по другой причине.
- Отчёт 1С «Обороты счета» (1 файл, ГК Сейма): внутри «Оборот Дт/Кт» колонки корреспондирующих счетов, это отдельная
форма. Синоним `обороты\s+счета` не добавляется.
- Правило «расшифровка без кодов ББ не может быть Готово» (вторая половина AURORA-1718) — пакет `arm_statements`,
ждёт AURORA-1677.
**Anti-goals (провал, даже если тесты зелёные):**
- Лист формы ББ/ОФР, её продолжения или расшифровки становится ОСВ по запасному признаку или по новому названию
ведомости, хотя ведомостью не является. Ведомость, которую сегодня ложно забирает конвейер форм (10 файлов «Оборотный
баланс», AURORA-1718), становится ОСВ по названию — это цель, а не ухудшение (признак по названию проверяется раньше
форм, как сейчас).
- `if` под файл, организацию, систему-источник или имя листа вместо синонима в YAML.
## Decisions
### D1. Запасной признак листа — последним шагом (решение владельца 28.10.2026)
Порядок классификации: признак ОСВ по названию (`sheet_markers`) → форма / продолжение / прочее (пакет). Только если
пакет вернул «прочее», лист проверяется запасным признаком — раньше слабого продолжения формы (R-12): в первых `SHEET_MARKER_ROWS` строках есть строка групп, где
найдены **все три** группы (начало, обороты, конец), и у каждой группы найдены **обе** стороны — Дебет и Кредит
(в ячейках под группой или в самой ячейке группы), и между ними в строках заголовка нет других подписей (R-11).
Свёрнутое сальдо (одна колонка в группе) запасной признак не даёт.
Нашёлся — лист `OSV`, дальше обычный конвейер ОСВ; нет — `SHEET_OTHER`, как сейчас.
Почему так: расшифровки ББ с колонками «Остаток на начало / … / Остаток на конец» забирает классификатор форм раньше,
до запасного признака они не доходят; поиск заголовка выполняется только на листах «прочее», а не на каждом листе книги
с формами. Требование трёх групп и обеих сторон строже обычного заголовка ОСВ («начало» там необязательно), поэтому
ложное срабатывание требует почти полной таблицы ОСВ.
### D2. Новые записи словарей
Ориентир — рекомендации из AURORA-1717/1719/1732; окончательные паттерны подбирает исполнитель по образцам, каждая
запись — с синтетикой, где она срабатывает (R-22). Паттерны пишутся по нормализованному тексту (нижний регистр, «ё» →
«е», дефисы и переносы → пробел).
| Ключ | Написания |
|---|---|
| `sheet_markers` | «Оборотная ведомость …», «ОСВ по сч. …» (с начала ячейки), «Оборотный баланс», «Фин. Обороты Листок», «Отчет по Главной книге» |
| `groups.opening` | «Остаток на начало (периода)», «Начальное сальдо» (уже есть `\bнач(альн\w*)?\.?\s*сальдо`), «Сальдо начальное…», «Сальдо вх …», «НачалоДт / НачалоКт» |
| `groups.closing` | симметрично: «Остаток на конец», «Конечное сальдо», «Сальдо конечное…», «Сальдо исх …», «КонецДт / КонецКт» |
| `debit` / `credit` | «по дебету» / «по кредиту» (в остатке ячейки группы «Оборот по дебету»); сторона, приклеенная к названию без границы слова («…ДТВВ», «…КТВВ», «НачалоДт») |
| `total_row_exact` | «ВСЕГО» |
Если сторону без границы слова нельзя выразить записью словаря при текущем `_side` (поиск с начала остатка ячейки),
допустима правка поиска стороны в `detect.py`, общая для всех написаний, без упоминания конкретной системы.
### D3. Регрессия по банку первички
До изменений и после — прогон парсера (CLI, `ENV_FILE=.env.test`) по всем файлам банка с `route = парсер` (xls, xlsx,
xlsb) на main `a8c9605` и на ветке. Сравнение по каждому документу: вид (ОСВ / форма / прочее), статус, код ошибки.
Ухудшение — `done` → `needs_check`/ошибка, `needs_check` → ошибка, любой переход форма/расшифровка → ОСВ через запасной
признак, переход форма/расшифровка → ОСВ по названию ведомости или «прочее» → ОСВ, если лист не ведомость (каждый такой
переход по названию просматривается вручную: ожидаемы «Оборотный баланс» и другие ведомости из списков 1717/1719).
Каждое ухудшение разбирается до PR: либо исправляется, либо выносится владельцу с образцом.
Банк — `awp-ca/doc-bank/` (в worktree — симлинк `.wt/osv-dict/doc-bank`), только чтение. Базовый прогон на main
`a8c9605` снимается до первой правки кода. Скрипт прогона и сравнения и его результаты — `awp-ca/admin/osv-other-systems-regress/`
(не репозиторий; в PR — только итоговая таблица).
Приёмка: сценарии спеки на синтетике, зелёный `make check-full`, нет ухудшений D3. Число файлов банка, перешедших в
«Готово» / «Требует проверки», — отчёт, а не порог. Реальные безымянные листы, которые конвейер форм относит к форме или
продолжению (до запасного признака они не доходят), остаются как есть и перечисляются в PR как остаток. Итоговая
таблица переходов статусов (было → стало, число файлов) — в описание PR и комментарий к AURORA-1717/1719. Плюс обычные
`make check-full` (golden, property, покрытие словарей) и `tests/fixtures/test_osv_dkb.py`.
### D4. Правило в 05-domain-rules.md
§1.1: признак листа — название ведомости по словарю `sheet_markers` (примеры написаний) или, если лист не опознан как
форма ББ/ОФР или её продолжение, полный заголовок таблицы ОСВ в первых 30 строках (D1). §1.2: варианты написания групп
и сторон дополняются написаниями из D2. PR в arm-ka-docs из ветки `feat/81-osv-other-systems` worktree
`.wt/osv-dict/docs`, ссылка на tabular-parser#81.
## Итог регрессии (28.10.2026)
Банк первички, 2152 файла (`admin/osv-other-systems-regress/report.md` workspace): 60 файлов из ошибки → ОСВ «Готово»,
70 → «Требует проверки», 40 узнаны как ОСВ, но стоят на 1720–1723; ни один лист формы или прочий лист книги с формами
не стал ОСВ; ухудшения — только R-14. Файлы списков 1717/1719 (175): 60 «Готово», 60 «Требует проверки», 50 ошибка
формата, 4 ложных «Готово» безымянных ведомостей 009, которые забирает конвейер форм (D1), 1 — неполный заголовок.
Контрольный набор синтетики s48–s57: позитивы на базе падали, негативы проходили; на ветке проходят все. Мутации
запасного признака (без R-11, без лимита 30 строк, со свёрнутым сальдо, с двумя группами) и снятие якоря `^` у названий
ловит каждая свой негатив (s55, s56, s57).
## Risks / Trade-offs
- [`^осв\b` и «оборотн… ведомост» в тексте формы] → проверка ОСВ идёт первой; регрессия D3 ловит листы форм, ставшие
ОСВ; паттерн «ОСВ» — только с начала ячейки.
- [Расшифровка, не узнанная пакетом, проходит запасной признак] → D1 требует три группы и обе стороны; регрессия D3.
- [Новые синонимы групп матчат преамбулу шапки («Остаток на начало: …») как строку групп] → в такой строке нет
«оборотов», `_find_group_row` её пропускает (R-12); синтетика с преамбулой.
- [Сторона без границы слова («дт» внутри слова)] → искать её только в остатке ячейки группы после совпадения группы, не
в произвольных ячейках.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-28 | R-1. Отчёт 1С «Обороты счета» вне объёма, синоним не добавляется | автор AURORA-1717 проверил: с синонимом файл даёт «Требует проверки» с договорами в контрагентах — нужна отдельная форма | 1 файл остаётся `INVALID_DOCUMENT`; отдельная задача |
| 2026-10-28 | R-2. Запасной признак ищет строку групп только в первых `SHEET_MARKER_ROWS` (30) строках | у безымянных листов таблица начинается сразу; ограничение снижает время и ложные срабатывания на длинных листах «прочее» | безымянный лист с преамбулой длиннее 30 строк не распознаётся; поднимется регрессией D3 |
| 2026-10-28 | R-3. Запасной признак не принимает свёрнутое сальдо (одна колонка в группе) | строгость против ложных ОСВ (D1) | ОСВ без названия со свёрнутым сальдо не распознаётся |
| 2026-10-28 | R-5. Новые `sheet_markers` якорятся к началу ячейки (`^…`); якорь снимается для конкретного названия, только если по спискам 1717 файлы с ним иначе не находятся | «Оборотная ведомость», «Оборотный баланс» могут встретиться в примечаниях к ББ; признак по названию проверяется раньше форм | часть ведомостей с текстом перед названием не распознаётся; видно в регрессии D3 |
| 2026-10-28 | R-6. Запасной признак проверяет ту же строку групп, что возьмёт `find_table_header` (первую с «оборотами» и «концом» в первых 30 строках), и колонки ищет тем же `_columns_of_group`; строгие условия — только к этой строке (финальное ревью, I-1: иначе преамбула «Обороты: …» над шапкой давала ОСВ, разбор падал и ронял книгу; негатив s56 «Преамбула») | запасной признак и конвейер не должны расходиться в том, что считать заголовком | безымянная ОСВ с преамбулой «Обороты: …» над шапкой не распознаётся |
| 2026-10-28 | R-7. Порядок PR: `fixtures` (golden синтетики, отдельный коммит `make golden-update`) → tabular-parser → docs | golden-тесты tabular-parser читают `../fixtures` | — |
| 2026-10-28 | R-8. Приёмка — синтетика + `make check-full` + нет ухудшений D3; число исправленных файлов банка — отчёт, не порог | эксперимент автора задач — ориентир, а не обязательство; остаток держат 1720–1723 | — |
| 2026-10-28 | R-9. Написание «Остаток по дебету/кредиту на начало/конец» (6 файлов КТЗ/ЧАЗ) включено в словарь: группа «остаток» с опережающей проверкой, сторона — из остатка ячейки | та же семья «Остаток на начало/конец» (1719), решается записью словаря | лишняя запись, удаляется |
| 2026-10-28 | R-10. В синтетике s50 у SAP-листа обороты «Оборот Дт / Оборот Кт», а не реальное «Дт оборот» | сторона перед группой — AURORA-1722, вне объёма (R-4) | файлы Виолы остаются с ошибкой до 1722 |
| 2026-10-28 | R-11. Запасной признак требует, чтобы между колонками «Дебет» и «Кредит» каждой группы в строках заголовка не было других подписей | иначе 1С «Обороты счета» (Дт/Кт оборота разделены колонками корр. счетов) проходит как ОСВ вопреки R-1; держит негатив s55 | безымянная ОСВ с колонкой между Дт и Кт не распознаётся |
| 2026-10-28 | R-12. Запасной признак проверяется раньше слабого продолжения формы (лист из 1–2 кодов сразу после формы), но после сильных признаков формы и продолжения | на базе лист Navision после формы ББ пакет присоединял к ней как слабое продолжение (синтетика s52); полная таблица ОСВ — сильнее эвристики «1–2 кода» | лист-продолжение формы с полной таблицей ОСВ отделится от формы (у продолжения формы таких групп нет) |
| 2026-10-28 | R-13. Признак «ОСВ …» сужен до `^осв\s+по\s+(\w+\s+по\s+)?сч\b` вместо `^осв\b` | первый прогон банка: строка «ОСВ за квартал по счетам …» перечня документов на листе «Входные данные_ИП» шаблона банка делала лист ОСВ, его разбор падал, и вся книга расшифровок становилась ошибкой (86 файлов); все 20 реальных названий начинаются с «ОСВ по сч.» / «ОСВ по ДЗ по сч.»; держит негатив s55 | «ОСВ» с другим продолжением по названию не узнаётся (остаётся запасной признак) |
| 2026-10-28 | R-14. Два файла «Сводная ОСВ … (забаланс)»: ложное «Готово» расшифровки на базе → ошибка иерархии ОСВ на ветке принято, а не исправлено | это цель изменения (ведомость «Оборотный баланс / Оборотная ведомость» перестаёт быть расшифровкой, 1718); дальше файл держит иерархия «002.01: Название» — AURORA-1721 | у двух книг «Ошибка» вместо фальшивого «Готово» до 1721 |
| 2026-10-28 | R-4. AURORA-1722 (SAP со стороной перед группой, НОВО-МАР) не входит | в задачах указан порядок: после 1717/1719; причина варианта НОВО-МАР не разобрана | эти файлы остаются с ошибкой до 1722 |

View File

@ -0,0 +1,42 @@
## Why
Прогон банка первичных документов (AURORA-1706, архивы «ДКБ отчетность» 20.10.2026, 1681 табличная ОСВ) показал, что
парсер знает только ведомости 1С с названием «Оборотно-сальдовая ведомость» и написания групп сумм 1С. ОСВ других
учётных систем (SAP, Navision, выгрузки КТЗ) и отчёты 1С с другим названием отклоняются целиком:
- AURORA-1717 — 119 файлов `INVALID_DOCUMENT` «в книге нет листов ОСВ или форм»: «Оборотная ведомость», «ОСВ по сч.»,
«Оборотный баланс», «Фин. Обороты Листок» (Navision), «Отчет по Главной книге», листы без названия; ещё 10 файлов
«Оборотный баланс» получают ложное «Готово» как расшифровка формы (часть AURORA-1718);
- AURORA-1719 — 159 файлов `INVALID_FORMAT` «не найден заголовок таблицы ОСВ»: «Остаток на начало/конец», «Начальное /
Конечное сальдо», «Сальдо вх/исх Дт» (SAP), «НачалоДт/КонецКт» (Navision), «Оборот по дебету/кредиту» (группа и
сторона в одной ячейке), «Сальдо НачальноеДТВВ» (сторона приклеена к названию);
- AURORA-1732 — итог листа «ВСЕГО» (ГК КТЗ, сч. 009) не распознаётся как «Итого» («ИТОГ» и «ИТОГ по …» уже добавлены
AURORA-1714).
Gitea: RSHB/arm-tabular-parser#81.
## What Changes
- Признаки листа ОСВ (`sheet_markers`): синонимы названий ведомости других систем и отчётов 1С.
- Запасной признак листа ОСВ: лист, который не опознан ни как ОСВ по названию, ни как форма ББ/ОФР или её продолжение,
считается ОСВ, если в его первых строках найден полный заголовок таблицы ОСВ (три группы сумм, у каждой — Дебет и
Кредит). Проверка выполняется последней, только для листов, которые иначе ушли бы в «прочее» (`SHEET_OTHER`).
- Синонимы групп сумм (`groups.opening`, `groups.closing`) и сторон (`debit`, `credit`), включая сторону в одной
ячейке с группой («Оборот по дебету», «НачалоДт») и приклеенную к названию группы («НачальноеДТВВ»).
- Итоговая строка «ВСЕГО» (`total_row_exact`).
- Синтетика на каждое новое написание (правило покрытия словарей R-22).
- `05-domain-rules.md` §1.1–§1.2 — правило поиска листа и написания групп (отдельный PR в arm-ka-docs, та же ветка
`feat/81-osv-other-systems`).
## Capabilities
### Modified Capabilities
- `osv-parsing`: признак листа ОСВ — название ведомости или (запасной путь) полный заголовок таблицы; написания групп и
сторон других учётных систем.
## Impact
- `api/rules/osv_headers.yaml`, `api/src/parser/detect.py` (классификация листа, поиск стороны), синтетика
`tests/synthetic/generate.py`, golden-снимки синтетики (отдельный коммит в `fixtures`).
- Контракт результата не меняется. arm-core не меняется.
- Пакет `arm_statements` не меняется (не ждёт AURORA-1677).

View File

@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: Листы ОСВ находятся по содержимому
Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости
всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и
дефисов). Признаки ОСВ — названия ведомости разных учётных систем («Оборотно-сальдовая ведомость», «Оборотная
ведомость», «ОСВ по сч.», «Оборотный баланс», «Фин. Обороты», «Отчет по Главной книге»). Лист с признаками формы ББ/ОФР
или её продолжения уходит в конвейер форм (спека `statement-parsing`). Лист, не опознанный ни как ОСВ по признаку, ни
как форма или её продолжение по признакам форм, SHALL считаться листом ОСВ по запасному признаку, если в первых
`SHEET_MARKER_ROWS` строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец»), у каждой
группы найдены обе стороны — «Дебет» и «Кредит», и между колонками «Дебет» и «Кредит» группы в строках заголовка нет
других подписей; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак MUST
проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или
расшифровки; слабое продолжение (лист из 1–2 кодов сразу после формы) признаком формы не считается — лист с полной
таблицей ОСВ после формы — ОСВ. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`,
`locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`,
`error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с
`locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска.
#### Scenario: Один лист — одна ОСВ (все реальные фикстуры)
- **WHEN** разбирается любой файл из `fixtures/osv/**`
- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done`
#### Scenario: Два листа ОСВ и посторонний лист
- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62»
- **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"`
#### Scenario: Нет ОСВ
- **WHEN** разбирается книга с одним листом произвольной таблицы
- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов
#### Scenario: Название ведомости другой учётной системы
- **WHEN** разбирается синтетическая ОСВ, у которой в шапке вместо «Оборотно-сальдовая ведомость» стоит одно из
названий «Оборотная ведомость по счету 009», «ОСВ по сч. 41 за 3 кв.», «Оборотный баланс», «Фин. Обороты Листок»,
«Отчет по Главной книге»
- **THEN** лист разобран как ОСВ (`osvDocument`), `SHEET_OTHER` для него нет
#### Scenario: Лист без названия ведомости — по заголовку таблицы
- **WHEN** разбирается синтетический лист без названия ведомости, таблица начинается в первых строках с групп
«НачалоДт | НачалоКт | ОборотДт | ОборотКт | КонецДт | КонецКт» (Navision)
- **THEN** лист разобран как ОСВ по запасному признаку
#### Scenario: Расшифровка формы не становится ОСВ
- **WHEN** разбирается книга с формой ББ и листом расшифровки с колонками «Остаток на начало | Поступило | Выбыло |
Остаток на конец»
- **THEN** вид листа расшифровки тот же, что без запасного признака (форма или продолжение конвейера форм либо
`SHEET_OTHER`), документа ОСВ из него нет
#### Scenario: Лист ОСВ без названия после формы — не слабое продолжение
- **WHEN** разбирается книга: форма ББ, за ней лист Navision без названия ведомости с полным заголовком таблицы, за ним
посторонний лист
- **THEN** форма — свой документ, лист Navision — `osvDocument`, посторонний лист — `SHEET_OTHER`
#### Scenario: Отчёт «Обороты счета» не ОСВ
- **WHEN** лист без названия ведомости содержит все три группы со сторонами, но между «Оборот Дт» и «Оборот Кт» стоят
колонки корреспондирующих счетов
- **THEN** лист пропускается с `SHEET_OTHER`
#### Scenario: Неполная таблица не даёт запасного признака
- **WHEN** лист без названия ведомости содержит группы «обороты» и «конец», но без группы «начало» или со свёрнутым
сальдо (одна колонка в группе)
- **THEN** лист пропускается с `SHEET_OTHER`
## ADDED Requirements
### Requirement: Написания групп и сторон других учётных систем
Словари `groups`, `debit`, `credit` SHALL знать написания групп и сторон выгрузок SAP, Navision и других систем: «Остаток
на начало/конец (периода)», «Начальное/Конечное сальдо», «Сальдо начальное/конечное», «Сальдо вх/исх», «НачалоДт/Кт»,
«КонецДт/Кт». Сторона SHALL находиться и в отдельной ячейке под группой, и в самой ячейке группы после её названия — в
том числе в форме «по дебету»/«по кредиту» («Оборот по дебету») и приклеенной к названию без пробела («Сальдо
НачальноеДТВВ», «НачалоДт»). Сторона без границы слова SHALL искаться только в остатке ячейки группы после совпадения
группы. Итоговая строка «ВСЕГО» SHALL распознаваться как итог листа наравне с «Итого» и «ИТОГ».
#### Scenario: Остаток и оборот по дебету
- **WHEN** разбирается синтетическая ОСВ с группами «Остаток на начало периода | Оборот по дебету | Оборот по кредиту |
Остаток на конец периода» и подзаголовками «Дебет | Кредит» под остатками
- **THEN** найдены все шесть колонок сумм; обороты — из ячеек «Оборот по дебету» и «Оборот по кредиту»
#### Scenario: Сторона приклеена к названию
- **WHEN** разбирается синтетическая ОСВ с заголовком «Сальдо НачальноеДТВВ | Сальдо НачальноеКТВВ | Оборот ДТ | Оборот
КТ | Сальдо КонечноеДТВВ | Сальдо КонечноеКТВВ»
- **THEN** найдены все шесть колонок сумм с верными сторонами
#### Scenario: SAP «Сальдо вх/исх»
- **WHEN** разбирается синтетическая ОСВ с группами «Сальдо вх Дт | Сальдо вх Кт | Оборот Дт | Оборот Кт | Сальдо исх Дт
| Сальдо исх Кт»
- **THEN** найдены все шесть колонок сумм
#### Scenario: Итог «ВСЕГО»
- **WHEN** разбирается синтетическая ОСВ, итоговая строка которой подписана «ВСЕГО»
- **THEN** строка — `TOTAL`, предупреждения «строка «Итого» не найдена» нет