Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63

Merged
aep merged 16 commits from feat/59-osv-flat-form into main 2026-10-19 13:21:55 +03:00
27 changed files with 1869 additions and 53 deletions

View File

@ -61,7 +61,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок - `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок
(`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре. (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре.
- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка - `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка
и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `inn_rows.py` — и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → header_account → sums` (+ `trivial`); `inn_rows.py` —
строки ИНН рядом с контрагентом (OQ-163); `indicators.py` — строки-показатели (колонка «Показатели», «В валюте»); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` — строки ИНН рядом с контрагентом (OQ-163); `indicators.py` — строки-показатели (колонка «Показатели», «В валюте»); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла. оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла.
- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера. - `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера.
@ -75,7 +75,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 131 файл: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 36 синтетических) — регрессия, а не - Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 136 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 41 синтетический) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого». проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -105,6 +105,12 @@ openspec/ требования и дельта текущего ср
ParseOutcome`. Структура ОСВ ищется **по содержимому**: ни букв колонок, ни номеров строк, ни имён листов в коде нет ParseOutcome`. Структура ОСВ ищется **по содержимому**: ни букв колонок, ни номеров строк, ни имён листов в коде нет
(это проверяет AST-линтер, см. ниже), синонимы лежат в `api/rules/*.yaml`. (это проверяет AST-линтер, см. ниже), синонимы лежат в `api/rules/*.yaml`.
**Плоская форма 1С по одному счёту (#59, AURORA-1661):** «по счету: X» в шапке задаёт счёт всем строкам, строка вида
субконто между названием и периодом не считается организацией, «Итого развернутое» — итоговая строка в любом порядке
относительно «Итого», плоский список без строк-счетов разбирается стратегией `header_account`, а аналитика расчётных
счетов (`counterparty_accounts` в `osv_groups.yaml`) без названия вида получает `COUNTERPARTY` с предупреждением.
Правила — `../docs/context/05-domain-rules.md` §1.1, §1.3, §1.4.
### Модули (`api/src/parser/`) ### Модули (`api/src/parser/`)
Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают
@ -117,7 +123,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `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` в домене нет | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
| `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | | `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации |
| `indicators.py` | строки-показатели (§1.3): метка вида суммы из колонки «Показатели» или текста строки (`read_label`), привязка показателя к строке данных над ним (`levels_with_indicators`) | | `indicators.py` | строки-показатели (§1.3): метка вида суммы из колонки «Показатели» или текста строки (`read_label`), привязка показателя к строке данных над ним (`levels_with_indicators`) |
@ -176,10 +182,12 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень». сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».
**«Итого».** Основная итоговая строка — первая TOTAL, не являющаяся показателем; она сверяется с первым корнем ACCOUNT либо, если корней **«Итого».** Основная итоговая строка — первая TOTAL листа, не являющаяся показателем и не «Итого развернутое»; она сверяется с первым корнем ACCOUNT либо, если корней
уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0
(OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая
строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм строка в итоговом блоке, и перед «Итого», и после неё («Итого развернутое», `total_row_secondary`), — тоже `TOTAL`, в сверку
«Итого» не входит. Тождество строки на ней пропускается, только если обороты пусты и на листе есть другая «Итого» (перед
«Итого» в 1С только развёрнутые сальдо); с оборотами или единственная итоговая — проверяется как обычная. Строка сумм
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из

View File

@ -4,6 +4,9 @@ mapping:
- ['^(счета?|субконто):?$', ACCOUNT] - ['^(счета?|субконто):?$', ACCOUNT]
- ['^подразделени', DEPARTMENT] - ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY] - ['^контрагент', COUNTERPARTY]
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число
# «Организация» — собственная организация учёта (измерение шапки), контрагентом не является
- ['^организации\b', COUNTERPARTY]
- ['^договор', CONTRACT] - ['^договор', CONTRACT]
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
@ -11,7 +14,13 @@ mapping:
- ['^нематериальн\w*\s+актив', OTHER] - ['^нематериальн\w*\s+актив', OTHER]
- ['^номенклатур', OTHER] - ['^номенклатур', OTHER]
- ['^стать(и|я)\b', OTHER] - ['^стать(и|я)\b', OTHER]
- ['^валют', OTHER] # строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
- ['^(в\s+)?валют', OTHER]
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.
counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80']
# Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка # Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка
default: OTHER default: OTHER

View File

@ -51,7 +51,8 @@ total_row:
total_row_exact: total_row_exact:
- '^итого$' - '^итого$'
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них # Вторая итоговая строка («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная. Стоит сразу после основной
# «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная
total_row_secondary: total_row_secondary:
- '^итого\s+развернут' - '^итого\s+развернут'
@ -95,3 +96,6 @@ header_markers:
subkonto: subkonto:
- '\bсубконто\b' - '\bсубконто\b'
- '\bдетализация\b' - '\bдетализация\b'
# плоская форма 1С: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» — организация после метки
chart_of_accounts:
- '\bплан\s+счетов\b'

View File

@ -32,7 +32,7 @@ from decimal import Decimal
from src.contract.result import OsvChecks from src.contract.result import OsvChecks
from src.parser.classify import SUM_FIELDS, ClassifiedRow from src.parser.classify import SUM_FIELDS, ClassifiedRow
from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote
from src.parser.rules import OsvCheckRules from src.parser.rules import OsvCheckRules, Pattern
__all__ = [ __all__ = [
"TOLERANCE", "TOLERANCE",
@ -75,15 +75,42 @@ def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> b
return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw) return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw)
def balance_identity(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]: def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bool:
return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary)
def _has_turnovers(item: ClassifiedRow) -> bool:
"""В строке есть хотя бы одно значение оборотов за период (дебет или кредит; пусто = нет, 0 — значение)."""
return any(value is not None for value in item.body.sums[_TURNOVER])
def balance_identity(
rows: Sequence[ClassifiedRow],
exempt: OsvCheckRules | None = None,
secondary_totals: Sequence[Pattern] = (),
) -> list[int]:
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого». """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка
(`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не
единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами
или единственная — проверяется как обычная строка. Итоговые строки-показатели («НУ»), идущие блоком сразу за
пропущенной второй итоговой строкой, пропускаются на тех же условиях (R-21); в счётчик TOTAL показатели не входят.
""" """
violations: list[int] = [] violations: list[int] = []
totals = sum(1 for item in rows if item.group_type == "TOTAL" and not item.indicator)
in_skipped_block = False
for item in rows: for item in rows:
if item.group_type == "TOTAL" and item.indicator:
if in_skipped_block and not _has_turnovers(item):
continue
else:
in_skipped_block = False
if _no_analytic_balance(item, exempt): if _no_analytic_balance(item, exempt):
continue continue
if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item):
in_skipped_block = True
continue
sums = _values(item) sums = _values(item)
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
if abs(expected - _net(sums[_CLOSING])) > TOLERANCE: if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
@ -145,9 +172,16 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
] ]
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None:
"""Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" """Основная «Итого» листа — первая строка TOTAL без признака показателя, чей текст не матчит вторые итоговые.
return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None)
Строки-показатели (НУ, Кол., Вал. под «Итого») отсекаются первыми (#58). Вторая итоговая строка («Итого
развернутое», `secondary`) в сверку не входит, даже если стоит перед «Итого» (#59); если других TOTAL нет —
служит единственной итоговой.
"""
totals = [item for item in rows if item.group_type == "TOTAL" and not item.indicator]
main = next((item for item in totals if not any(p.search(item.body.text) for p in secondary)), None)
return main or next(iter(totals), None)
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
@ -174,17 +208,22 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
return tuple(totals) return tuple(totals)
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool:
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01). """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или
нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов,
«Итого» равно их сумме.
""" """
total = _total(rows) total = _total(rows, secondary_totals)
if total is None: if total is None:
return False return False
root = _root(rows) root = _root(rows)
if root is None: if root is None:
roots = _level_zero(rows)
if roots:
return _sums_match(_values(total), _sum_of(roots))
return all(abs(value) <= TOLERANCE for value in _values(total)) return all(abs(value) <= TOLERANCE for value in _values(total))
if not _differs(_values(total), _values(root)): if not _differs(_values(total), _values(root)):
return True return True
@ -202,12 +241,15 @@ def _rows_text(rows: Iterable[int]) -> str:
return shown return shown
def _total_warning(rows: Sequence[ClassifiedRow]) -> str: def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> str:
total = _total(rows) total = _total(rows, secondary)
root = _root(rows) root = _root(rows)
if total is None: if total is None:
return TOTAL_NOT_FOUND_WARNING return TOTAL_NOT_FOUND_WARNING
if root is None: if root is None:
level_zero = _level_zero(rows)
if level_zero:
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ сумме {len(level_zero)} строк уровня 0"
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет" return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})" message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
level_zero = _level_zero(rows) level_zero = _level_zero(rows)
@ -216,14 +258,21 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
return message return message
def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[OsvNote]]: def run_checks(
rows: Sequence[ClassifiedRow],
exempt: OsvCheckRules | None = None,
*,
secondary_totals: Sequence[Pattern] = (),
) -> tuple[OsvChecks, list[OsvNote]]:
"""Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они; тождество на них
пропускается только без оборотов, когда есть другая «Итого» (#59, R-19).
""" """
identity = balance_identity(rows, exempt) identity = balance_identity(rows, exempt, secondary_totals)
parents = parent_sums(rows, exempt) parents = parent_sums(rows, exempt)
matches = total_matches_root(rows) matches = total_matches_root(rows, secondary_totals)
warnings: list[OsvNote] = [] warnings: list[OsvNote] = []
if identity: if identity:
text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
@ -232,7 +281,7 @@ def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = Non
text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}" text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}"
warnings.append(OsvNote(OSV_PARENT_SUM, text)) warnings.append(OsvNote(OSV_PARENT_SUM, text))
if not matches: if not matches:
warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows))) warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows, secondary_totals)))
checks = OsvChecks( checks = OsvChecks(
total_row_matches_root=matches, total_row_matches_root=matches,
balance_identity_violations=identity, balance_identity_violations=identity,

View File

@ -83,6 +83,8 @@ class ClassifiedRow:
data_type: DataType | None data_type: DataType | None
extra: dict[str, str | None] | None extra: dict[str, str | None] | None
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
counterparty_by_account: bool = False
# --- Строки таблицы --- # --- Строки таблицы ---
@ -196,6 +198,22 @@ def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], tot
return last return last
def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет).
Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Строки-показатели
(«НУ» под «Итого развернутое») шагу не мешают, но в блок входят, только если выше них найдена вторая итоговая
строка (R-21): «НУ» под последней строкой данных в блок не втягивается.
"""
first = total
for row in reversed(rows[: rows.index(total)]):
if _is_secondary_total(sheet.text_at(row, header.text_col), rules):
first = row
elif not is_indicator_total(sheet, row, header, rules):
break
return first
def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None: def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
"""Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1).""" """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1)."""
for group in columns.collapsed: for group in columns.collapsed:
@ -295,7 +313,8 @@ def collect_body_rows(
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения. """Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5); Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5);
исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL. исключение — вторые итоговые строки («Итого развернутое»): они остаются в таблице как TOTAL и стоят как сразу
за «Итого», так и перед ней (плоская форма 1С); возвращаемый номер — первая строка итогового блока.
""" """
warnings: list[str] = [] warnings: list[str] = []
if header.columns.collapsed: if header.columns.collapsed:
@ -313,6 +332,7 @@ def collect_body_rows(
if tail: if tail:
warnings.append(_tail_warning(tail)) warnings.append(_tail_warning(tail))
rows = rows[: last + 1] rows = rows[: last + 1]
total_row = _leading_totals(sheet, header, rows, total_row, rules)
label_cols = _label_cols(sheet, header, rows) label_cols = _label_cols(sheet, header, rows)
body: list[BodyRow] = [] body: list[BodyRow] = []
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
@ -369,6 +389,7 @@ class _Kind:
counterparty_name: str | None = None counterparty_name: str | None = None
counterparty_inn: str | None = None counterparty_inn: str | None = None
indicator: bool = False indicator: bool = False
counterparty_by_account: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@ -409,6 +430,19 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
position = inherited.depth + 1 position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position) group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position) branch = _Branch(code, position)
# Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов
# (решение владельца №3, R-6); строки, которые словарь групп явно знает как не-контрагентов (валюта), — нет (R-18)
known = context.rules.groups.known(item.text)
by_account = (
group_type == "OTHER"
and name is None
and position == 1
and position >= len(context.schema)
and (known is None or known == "COUNTERPARTY")
and context.rules.groups.is_counterparty_account(code)
)
if by_account:
group_type = "COUNTERPARTY"
if group_type != "COUNTERPARTY": if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, own), branch return _Kind(group_type, name, code, own), branch
inn_col = context.header.inn_col inn_col = context.header.inn_col
@ -416,7 +450,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules) counterparty, in_name = split_inn_suffix(item.text, context.rules)
inn = column or context.inns.get(item.row) or in_name inn = column or context.inns.get(item.row) or in_name
return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch kind = _Kind(
group_type,
name,
code,
own,
counterparty_name=counterparty,
counterparty_inn=inn,
counterparty_by_account=by_account,
)
return kind, branch
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
@ -431,10 +474,12 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
*, *,
inns: Mapping[int, str] | None = None, inns: Mapping[int, str] | None = None,
unpaired: AbstractSet[int] = frozenset(), unpaired: AbstractSet[int] = frozenset(),
header_account: str | None = None,
) -> list[ClassifiedRow]: ) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок. """Классификация строк по дереву `levels` и схеме группировок.
- «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя; - «Итого» (`total_row`) и вторые итоговые строки («Итого развернутое»; стоят после неё или перед ней) — TOTAL,
уровень 0 без родителя;
в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро);
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки; - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
- строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) — - строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) —
@ -444,12 +489,15 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
- вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`); - вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`);
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`. - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`;
- корневая ветка наследует счёт шапки `header_account` (R-4, #59): строки до первой строки-счёта и плоский список
получают его код.
""" """
context = _Context( context = _Context(
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
) )
extra_keys = _extra_keys(sheet, header, rows) extra_keys = _extra_keys(sheet, header, rows)
root_branch = _Branch(header_account, 0)
branches: dict[int, _Branch] = {} branches: dict[int, _Branch] = {}
result: list[ClassifiedRow] = [] result: list[ClassifiedRow] = []
for item in rows: for item in rows:
@ -461,7 +509,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
level, parent = 0, None level, parent = 0, None
else: else:
parent = levels.parent[item.row] parent = levels.parent[item.row]
inherited = _ROOT_BRANCH if parent is None else branches.get(parent, _ROOT_BRANCH) inherited = root_branch if parent is None else branches.get(parent, root_branch)
kind, branch = _classify_row(item, inherited, context) kind, branch = _classify_row(item, inherited, context)
level = levels.by_row[item.row] level = levels.by_row[item.row]
branches[item.row] = branch branches[item.row] = branch
@ -478,6 +526,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
data_type=kind.data_type, data_type=kind.data_type,
extra=_extra(sheet, item.row, extra_keys), extra=_extra(sheet, item.row, extra_keys),
indicator=kind.indicator, indicator=kind.indicator,
counterparty_by_account=kind.counterparty_by_account,
) )
) )
return result return result

View File

@ -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,28 @@ 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]:
"""Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп, не
ACCOUNT; метки шапки (в том числе валюта) видом субконто не считаются."""
cells = _head_cells(sheet, header)
currency = rules.headers.header_markers["currency"]
groups: list[OsvGroup] = []
for cell in _kind_row_cells(cells, rules):
# Метка валюты — с двоеточием или без него: «Валюта БУ, НУ» вид субконто не называет
if _find(currency, cell.text) is not None or _is_structural_marker(cell, rules):
continue
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 and known != "ACCOUNT":
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)

View File

@ -186,6 +186,17 @@ def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool:
return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True)) return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True))
def strategy_header_account(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
"""Плоский список под счётом шапки (#59): все строки — уровень 0.
Применима, только если ни одна строка тела не является счётом (`is_account`): счёт назван в шапке, а строки таблицы
— его аналитика без оформления. Условие «в шапке есть код счёта» проверяет `resolve_levels`.
"""
if any(item.is_account for item in _paced(rows, deadline, "иерархия (header_account)")):
return None
return {item.row: ROOT_LEVEL for item in rows}
def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
"""Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами. """Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами.
@ -269,9 +280,14 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
return result return result
def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: def resolve_levels(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
) -> Levels:
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
(плоский список под счётом шапки, #59).
«Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это «Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это
контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное
дерево корней `TRIVIAL_STRATEGY`. дерево корней `TRIVIAL_STRATEGY`.
@ -283,7 +299,11 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea
if len(body) < MIN_ROWS_FOR_CASCADE: if len(body) < MIN_ROWS_FOR_CASCADE:
by_row = {item.row: ROOT_LEVEL for item in rows} by_row = {item.row: ROOT_LEVEL for item in rows}
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY) return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
for name, strategy in STRATEGIES: cascade = list(STRATEGIES)
if header_account:
before_sums = next((index for index, (name, _) in enumerate(cascade) if name == "sums"), len(cascade))
cascade.insert(before_sums, ("header_account", strategy_header_account))
for name, strategy in cascade:
deadline.check(f"иерархия ({name})") deadline.check(f"иерархия ({name})")
found = strategy(body, deadline) found = strategy(body, deadline)
if found is None: if found is None:

View File

@ -60,13 +60,17 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU" return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU"
def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: def levels_with_indicators(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
) -> Levels:
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ. (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя. Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
""" """
levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline) levels = resolve_levels(
[item for item in rows if not item.indicator], total_row, deadline, header_account=header_account
)
by_row: dict[int, int] = {} by_row: dict[int, int] = {}
parent: dict[int, int | None] = {} parent: dict[int, int | None] = {}
previous: int | None = None previous: int | None = None

View File

@ -112,6 +112,16 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea
return replace(doc, account_code_raw=code, warnings=tuple(warnings)) return replace(doc, account_code_raw=code, warnings=tuple(warnings))
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
return [
f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — "
"проверьте"
for code in codes
]
def _is_ok(checks: OsvChecks) -> bool: def _is_ok(checks: OsvChecks) -> bool:
return ( return (
bool(checks.total_row_matches_root) bool(checks.total_row_matches_root)
@ -184,7 +194,7 @@ class OsvParser:
step = "строки таблицы" step = "строки таблицы"
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
step = "иерархия строк" step = "иерархия строк"
levels = levels_with_indicators(body, total_row, deadline) levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
step = "строки ИНН" step = "строки ИНН"
glue = glue_inn_rows( glue = glue_inn_rows(
body, body,
@ -213,18 +223,20 @@ class OsvParser:
rules, rules,
inns=glue.inns, inns=glue.inns,
unpaired=glue.unpaired, unpaired=glue.unpaired,
header_account=doc.account_code_raw or None,
) )
except InvalidFormatError as exc: except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc raise _invalid_format(sheet, step, exc) from exc
glue = _check_inn_owners(glue, classified) glue = _check_inn_owners(glue, classified)
doc = _with_table_account(doc, classified) doc = _with_table_account(doc, classified)
deadline.check(f"лист «{sheet.name}»: контрольные суммы") deadline.check(f"лист «{sheet.name}»: контрольные суммы")
checks, check_notes = run_checks(classified, rules.checks) checks, check_notes = run_checks(classified, rules.checks, secondary_totals=rules.headers.total_row_secondary)
notes = [ notes = [
hierarchy_note(levels.strategy), hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings), *notes_of(OSV_HEADER, doc.warnings),
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []), *([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
*notes_of(OSV_ROWS, body_warnings), *notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_ROWS, _by_account_warnings(classified)),
*notes_of(OSV_INN_ROWS, glue.warnings), *notes_of(OSV_INN_ROWS, glue.warnings),
] ]
if not any(item.group_type != "TOTAL" for item in classified): if not any(item.group_type != "TOTAL" for item in classified):

View File

@ -44,6 +44,7 @@ HIERARCHY_TEXTS = {
"outline": "по группировке листа (outline)", "outline": "по группировке листа (outline)",
"indent": "по отступам (indent)", "indent": "по отступам (indent)",
"account_pattern": "по кодам счетов (account_pattern)", "account_pattern": "по кодам счетов (account_pattern)",
"header_account": "плоский список под счётом шапки (header_account)",
"sums": "по суммам (sums)", "sums": "по суммам (sums)",
"trivial": "плоский список (trivial)", "trivial": "плоский список (trivial)",
} }

View File

@ -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"]
@ -184,32 +184,56 @@ class OsvHeaderRules(_Model):
return self return self
def _check_account_codes(field_name: str, codes: Iterable[str]) -> None:
bad = [code for code in codes if not _ACCOUNT_CODE.fullmatch(code)]
if bad:
raise ValueError(f"{field_name}: ожидаются коды счетов вида «91» или «76.09», {bad}")
def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool:
"""Код счёта из файла («91», «91.01») совпадает с одним из `roots` либо является его субсчётом (не «910»)."""
if not account_code:
return False
code = account_code.strip()
return any(code == root or code.startswith(f"{root}.") for root in roots)
class OsvGroupRules(_Model): class OsvGroupRules(_Model):
mapping: list[tuple[Pattern, GroupType]] mapping: list[tuple[Pattern, GroupType]]
default: GroupType = "OTHER" default: GroupType = "OTHER"
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
counterparty_accounts: list[str] = []
@model_validator(mode="after")
def _check_counterparty_accounts(self) -> "OsvGroupRules":
_check_account_codes("counterparty_accounts", self.counterparty_accounts)
return self
def is_counterparty_account(self, account_code: str | None) -> bool:
"""Счёт из `counterparty_accounts` или его субсчёт («76» -> 76, 76.2, 76.Н.1; не 760)."""
return _is_under_account(account_code, self.counterparty_accounts)
def classify(self, text: str) -> GroupType: def classify(self, text: str) -> GroupType:
"""Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`.""" """Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
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]
@model_validator(mode="after") @model_validator(mode="after")
def _check_codes(self) -> "OsvCheckRules": def _check_codes(self) -> "OsvCheckRules":
bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)] _check_account_codes("accounts_without_analytic_balance", self.accounts_without_analytic_balance)
if bad:
raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}")
return self return self
def has_no_analytic_balance(self, account_code: str | None) -> bool: def has_no_analytic_balance(self, account_code: str | None) -> bool:
"""Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт.""" """Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт."""
if not account_code: return _is_under_account(account_code, self.accounts_without_analytic_balance)
return False
code = account_code.strip()
return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance)
class PeriodRule(_Model): class PeriodRule(_Model):

View File

@ -3,7 +3,8 @@
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md` строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком); описание — `osv/synthetic/README.md`
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
@ -861,6 +862,116 @@ def _s32() -> list[SheetSpec]:
return [sheet] return [sheet]
FlatRow = tuple[str, tuple[str | None, ...], int]
def _flat_sheet(texts: dict[int, str], group_row: int, rows: Sequence[FlatRow]) -> SheetSpec:
"""Плоская форма 1С (#59): колонка A — текст, B…G — суммы «начало / обороты / конец» × «Дебет | Кредит»; заголовок
«Субконто» + группы (объединения B:C, D:E, F:G) в `group_row`, подзаголовки под ним, данные сразу ниже. `texts` —
строки шапки над таблицей; в `rows` пустая сумма (None) — нет ячейки, отступ — выравнивание ячейки."""
sheet = SheetSpec(SHEET)
for row, text in texts.items():
sheet.put(row, 1, text)
sheet.put(group_row, 1, "Субконто")
for index, key in enumerate(("opening", "turnover", "closing")):
col = 2 + index * PAIR_WIDTH
sheet.put(group_row, col, GROUP_TITLES[key])
sheet.merge(group_row, col, group_row, col + 1)
sheet.put(group_row + 1, col, DEBIT)
sheet.put(group_row + 1, col + 1, CREDIT)
for offset, (text, sums, indent) in enumerate(rows):
row = group_row + 2 + offset
sheet.put(row, 1, text, indent=indent)
for col, value in enumerate(sums, start=2):
if value is not None:
sheet.put(row, col, D(value))
return sheet
def _s40() -> list[SheetSpec]:
"""Плоская форма (#59): счёт в заголовке «по счету: 76.Н.1» и вид аналитики «Контрагенты» строкой ниже, данные без
outline и отступов; «Итого развернутое» — сумма строк, «Итого» — свёрнутое сальдо."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 76.Н.1",
3: "Контрагенты",
4: "за 2025 г.",
5: 'ООО "Товарное хозяйство"',
7: "Единица измерения: рубль (код по ОКЕИ 383)",
}
rows: list[FlatRow] = [
("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0),
("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0),
("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0),
("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0),
("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0),
]
return [_flat_sheet(texts, 9, rows)]
def _s41() -> list[SheetSpec]:
"""Плоская форма (#59): вид аналитики «Организации», период «за 2 Квартал 2026 г.» со словами «План счетов …»."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 60",
3: "Организации",
4: "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива",
}
rows: list[FlatRow] = [
("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0),
("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0),
("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0),
("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0),
]
return [_flat_sheet(texts, 5, rows)]
def _s42() -> list[SheetSpec]:
"""Плоская форма (#59): под счётом 76 идут субсчета «76.2», «76.5», «76.Н», «76.Н.1»; «Итого развернутое» перед
«Итого»."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 76",
3: "за 2025 г.",
4: 'ООО "Товарное хозяйство"',
6: "Единица измерения: рубль (код по ОКЕИ 383)",
}
rows: list[FlatRow] = [
("76.2", ("1000.00", None, None, "1000.00", None, None), 0),
("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0),
("76.Н", (None, None, "5000.00", "5000.00", None, None), 0),
("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0),
("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0),
("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0),
]
return [_flat_sheet(texts, 8, rows)]
def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]:
"""Счёт с отступами: корень-субсчёт (без отступа) и два элемента аналитики с отступом 1, без вида аналитики в шапке;
«Итого» перед «Итого развернутое»."""
texts = {
1: 'ООО "Октябрьское"',
2: f"Оборотно-сальдовая ведомость по счету {account}",
3: "Период: 9 месяцев 2024 г.",
}
rows: list[FlatRow] = [
(root, ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
(kids[0], ("34618000.00", None, None, "34618000.00", None, None), 1),
(kids[1], (None, None, "4236500.00", "4236500.00", None, None), 1),
("Итого", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
("Итого развернутое", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
]
return [_flat_sheet(texts, 5, rows)]
def _s43() -> list[SheetSpec]:
"""Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом)."""
return _s43_s44("60", "60.01", ("ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'))
def _s44() -> list[SheetSpec]:
"""То же, что s43, но счёт 10: номенклатура вместо контрагентов."""
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
def _s28() -> list[SheetSpec]: def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree() tree = _inn_layout_tree()
@ -969,6 +1080,11 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx), "s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx), "s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx), "s36_no_data_type_header.xlsx": (_s36, to_xlsx),
"s40_flat_header_account.xlsx": (_s40, to_xlsx),
"s41_flat_chart_of_accounts.xlsx": (_s41, to_xlsx),
"s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx),
"s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx),
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
} }
FILE_NAMES = tuple(FILES) FILE_NAMES = tuple(FILES)
@ -987,7 +1103,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate", prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.", description="Синтетические ОСВ s01…s36, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.",
) )
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv) args = parser.parse_args(argv)

View File

@ -26,12 +26,14 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: def test_generated_set_is_s01_to_s36_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)] names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES) assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)] # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
numbers = [*range(1, 37), *range(40, 45)]
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"]
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -42,6 +42,15 @@ XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
# Плоская форма ОСВ 1С (#59)
FLAT_FORM_FILES = (
"s40_flat_header_account.xlsx",
"s41_flat_chart_of_accounts.xlsx",
"s42_subaccounts_totals_reversed.xlsx",
"s43_subkonto_unnamed_60.xlsx",
"s44_subkonto_unnamed_10.xlsx",
)
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [ EXPECTED_NAMES = [
"s01_variant2_header.xlsx", "s01_variant2_header.xlsx",
@ -73,6 +82,7 @@ EXPECTED_NAMES = [
"s34_indicator_column_qty_val_diff.xlsx", "s34_indicator_column_qty_val_diff.xlsx",
"s35_currency_slices.xlsx", "s35_currency_slices.xlsx",
"s36_no_data_type_header.xlsx", "s36_no_data_type_header.xlsx",
*FLAT_FORM_FILES,
] ]
XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -152,6 +162,27 @@ EXPECTED: dict[str, Expect] = {
"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False),
"s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False),
"s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), "s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),
# плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `header.data_type = None`; строки — BU (#58)
# счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.»
"s40_flat_header_account.xlsx": Expect(
"header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# вид «Организации», «за 2 Квартал 2026 г. План счетов …»
"s41_flat_chart_of_accounts.xlsx": Expect(
"header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива"
),
# субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого»
"s42_subaccounts_totals_reversed.xlsx": Expect(
"account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# счёт 60 без вида аналитики в шапке, отступы
"s43_subkonto_unnamed_60.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
# то же на счёте 10: материалы остаются OTHER
"s44_subkonto_unnamed_10.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
} }
@ -229,7 +260,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s36 (s13 — xlsb; допустима деградация: нет только его).""" """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; без него допустимо)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present] assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present] missing = [name for name in EXPECTED_NAMES if name not in present]
@ -692,6 +723,20 @@ def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> No
assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome) assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome)
def test_s42_secondary_total_before_main_total(fixtures_dir: Path) -> None:
"""#59: «Итого развернутое» перед «Итого» — обе TOTAL, сверка по основной «Итого», статус done."""
outcome = parse_named(fixtures_dir, "s42_subaccounts_totals_reversed.xlsx")
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE
assert doc.status == "done"
assert [row.group_type for row in doc.rows[-2:]] == ["TOTAL", "TOTAL"]
assert doc.checks is not None
assert doc.checks.total_row_matches_root is True
assert doc.checks.parent_sum_violations == []
assert doc.checks.balance_identity_violations == []
def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None: def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
"""I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»)."""
doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE))

View File

@ -10,7 +10,7 @@ from src.contract.common import DataType
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
from src.parser.classify import ClassifiedRow, RowGroupType from src.parser.classify import ClassifiedRow, RowGroupType
from src.parser.hierarchy import BodyRow from src.parser.hierarchy import BodyRow
from src.parser.rules import OsvCheckRules from src.parser.rules import OsvCheckRules, Pattern
Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None] Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
EMPTY: Sums = (None, None, None, None, None, None) EMPTY: Sums = (None, None, None, None, None, None)
@ -241,6 +241,79 @@ def test_rows_without_children_are_not_parents() -> None:
assert parent_sums(rows) == [] assert parent_sums(rows) == []
def test_total_check_uses_main_total_when_secondary_comes_first() -> None:
"""Плоская форма (#59): «Итого развернутое» перед «Итого» — сверка по основной «Итого»."""
secondary = [Pattern.compile(r"^итого\s+развернут")]
rows = [
account(7, ROOT),
row(8, ("1", None, None, None, None, "1"), group_type="TOTAL", text="Итого развернутое"),
total(9, ROOT),
]
assert total_matches_root(rows, secondary) is True
assert total_matches_root(rows) is False
checks, notes = run_checks(rows, secondary_totals=secondary)
assert checks.total_row_matches_root is True
assert not any(note.code == "OSV_TOTAL" for note in notes)
SECONDARY = [Pattern.compile(r"^итого\s+развернут")]
# «Итого развернутое» перед «Итого» несёт только развёрнутые сальдо, без оборотов: тождество на ней не выполняется
SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5")
def _secondary(number: int, sums: Sums) -> ClassifiedRow:
return row(number, sums, group_type="TOTAL", text="Итого развернутое")
def test_balance_identity_skips_secondary_total_without_turnovers_when_not_the_only_total() -> None:
rows = [*tree(), _secondary(11, SECONDARY_ROW)]
assert balance_identity(rows, secondary_totals=SECONDARY) == []
checks, notes = run_checks(rows, secondary_totals=SECONDARY)
assert checks.balance_identity_violations == []
assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes)
def test_balance_identity_checks_secondary_total_with_turnovers() -> None:
"""R-19: «Итого развернутое» после «Итого» несёт обороты — тождество проверяется, сломанное даёт нарушение."""
broken: Sums = ("100", None, "50", "30", "121", None)
rows = [*tree(), _secondary(11, broken)]
assert balance_identity(rows, secondary_totals=SECONDARY) == [11]
checks, notes = run_checks(rows, secondary_totals=SECONDARY)
assert checks.balance_identity_violations == [11]
assert any(note.code == "OSV_BALANCE_IDENTITY" for note in notes)
def test_balance_identity_accepts_secondary_total_with_turnovers_that_balance() -> None:
rows = [*tree(), _secondary(11, ROOT)]
assert balance_identity(rows, secondary_totals=SECONDARY) == []
def test_balance_identity_checks_only_total_even_if_secondary_without_turnovers() -> None:
"""Единственная итоговая строка проверяется, даже если она вторая и без оборотов."""
rows = [account(7, ROOT), _secondary(8, SECONDARY_ROW)]
assert balance_identity(rows, secondary_totals=SECONDARY) == [8]
def test_balance_identity_still_checks_main_total_with_secondary_present() -> None:
broken: Sums = ("100", None, "50", "30", "121", None)
rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)]
assert balance_identity(rows, secondary_totals=SECONDARY) == [8]
def test_balance_identity_without_secondary_patterns_checks_every_total() -> None:
rows = [*tree(), _secondary(11, SECONDARY_ROW)]
assert balance_identity(rows) == [11]
checks, _ = run_checks(rows)
assert checks.balance_identity_violations == [11]
def test_total_matches_first_root_account() -> None: def test_total_matches_first_root_account() -> None:
rows = tree() rows = tree()
@ -339,6 +412,29 @@ def test_second_total_row_does_not_replace_the_first_one() -> None:
assert total_matches_root(rows) is True assert total_matches_root(rows) is True
def test_single_total_matching_secondary_is_used_as_the_total() -> None:
"""T3.1: единственная TOTAL, чей текст матчит вторые итоговые, — всё равно итоговая строка."""
secondary = [Pattern.compile(r"^итого\s+развернут")]
only = row(8, ROOT, group_type="TOTAL", text="Итого развернутое")
assert total_matches_root([account(7, ROOT), only], secondary) is True
def test_total_without_account_root_matches_level_zero_sum() -> None:
flat = [
row(7, ("10", None, "5", "3", "12", None), group_type="OTHER"),
row(8, (None, "4", "1", "2", None, "3"), group_type="COUNTERPARTY"),
row(9, ("6", None, "2", "1", "7", None), group_type="COUNTERPARTY"),
]
# сумма корней: дебет/кредит сальдо 16/4 -> 12 нетто, на конец 19/3 -> 16 нетто; обороты 8/6 по колонкам
good = total(10, ("12", None, "8", "6", "16", None))
bad = total(10, ("12", None, "9", "6", "16", None))
assert total_matches_root([*flat, good]) is True
assert total_matches_root([*flat, bad]) is False
assert total_matches_root([total(10, EMPTY)]) is True
def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None: def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
assert total_matches_root([total(8, EMPTY)]) is True assert total_matches_root([total(8, EMPTY)]) is True
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True

View File

@ -1112,3 +1112,122 @@ def test_row_without_label_takes_document_data_type(rules: Rules) -> None:
"60": "NU", "60": "NU",
"ООО Поставщик": "NU", "ООО Поставщик": "NU",
} }
def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> None:
"""Плоская форма (#59): «Итого развернутое» стоит перед «Итого» — обе строки итоговый блок, `total_row` — первая."""
sheet = osv(
r(0, "60.01", S100),
r(0, "60.02", S100),
r(None, "Итого развернутое", (1, None, 2, 3, 4, None)),
r(None, "Итого", (200, None, 100, 60, 240, None)),
)
parsed = parse(sheet, rules)
secondary, main = parsed.one("Итого развернутое"), parsed.one("Итого")
assert parsed.total_row == secondary.body.row
assert [row.group_type for row in (secondary, main)] == ["TOTAL", "TOTAL"]
assert [row.level for row in (secondary, main)] == [0, 0]
assert [row.parent for row in (secondary, main)] == [None, None]
assert parsed.one("60.02").group_type != "TOTAL"
def test_only_secondary_total_is_the_total(rules: Rules) -> None:
"""Без обычной «Итого» «Итого развернутое» — единственная итоговая строка."""
sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100))
parsed = parse(sheet, rules)
secondary = parsed.one("Итого развернутое")
assert parsed.total_row == secondary.body.row
assert secondary.group_type == "TOTAL"
# --- Контрагенты расчётных счетов без названия вида (#59, R-6) ---
def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet:
"""Схема из одной ACCOUNT («Субконто»); `label` — вторая группировка в блоке заголовка."""
block: list[list[object]] = [["Субконто", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]]
block.append([label, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"])
return sheet_from_rows([["ООО Ромашка"], [TITLE], [], *block, *rows])
def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Иванов А.А.", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("ИП Иванов А.А.")
assert row.group_type == "COUNTERPARTY"
assert row.group_name is None
assert row.counterparty_name == "ИП Иванов А.А."
assert row.counterparty_by_account is True
def test_unnamed_second_level_stays_other(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Иванов А.А.", *S100],
[L(2), "Договор № 1", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("Договор № 1")
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
def test_named_other_group_is_not_reclassified(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "76.01", *S100],
[L(1), "Станок", *S100],
[L(0), "Итого", *S100],
label="Основные средства",
)
parsed = parse(sheet, rules)
row = parsed.one("Станок")
assert row.group_type == "OTHER"
assert row.group_name == "Основные средства"
assert row.counterparty_by_account is False
def test_non_settlement_account_stays_other(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "10.01", *S100],
[L(1), "Мука пшеничная", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("Мука пшеничная")
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "Валюта CNY", "Валюта руб", "В валюте"])
def test_currency_row_under_settlement_account_is_not_counterparty(rules: Rules, text: str) -> None:
"""R-18: строка валюты под 76.55 — не контрагент по плану счетов, а контрагент рядом остаётся контрагентом."""
sheet = _subkonto_sheet(
[L(0), "76.55", *S100],
[L(1), "ООО Ромашка", *S100],
[L(1), text, *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one(text)
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
assert parsed.one("ООО Ромашка").group_type == "COUNTERPARTY"

View File

@ -10,7 +10,7 @@ from gridbuilder import M, at, sheet_from_rows
from src.parser.detect import TableHeader, find_table_header from src.parser.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,98 @@ 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", "Организации")]
def test_single_organization_kind_row_is_not_counterparty(rules: Rules) -> None:
"""R-17: «Организация» — собственная организация учёта (варианты 1/2 шапки), схема остаётся [ACCOUNT]."""
assert _flat_schema(rules, "Организация") == [("ACCOUNT", "Субконто")]
def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rules) -> None:
assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")]
def test_currency_row_without_colon_is_not_added_to_schema(rules: Rules) -> None:
assert _flat_schema(rules, "Валюта БУ, НУ") == [("ACCOUNT", "Субконто")]
def test_account_cell_between_title_and_period_does_not_add_second_account(rules: Rules) -> None:
assert _flat_schema(rules, "Счет") == [("ACCOUNT", "Субконто")]
def test_subkonto_cell_between_title_and_period_does_not_add_second_account(rules: Rules) -> None:
assert _flat_schema(rules, "Субконто") == [("ACCOUNT", "Субконто")]

View File

@ -23,6 +23,7 @@ from src.parser.hierarchy import (
parents, parents,
resolve_levels, resolve_levels,
strategy_account_pattern, strategy_account_pattern,
strategy_header_account,
strategy_indent, strategy_indent,
strategy_outline, strategy_outline,
strategy_sums, strategy_sums,
@ -456,3 +457,44 @@ def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name:
with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"): with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"):
strategy(rows, deadline) strategy(rows, deadline)
assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK
# --- Плоский список под счётом шапки (#59, стратегия header_account) ---
def test_header_account_strategy_flat_rows() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0, 0]
assert set(levels.parent.values()) == {None}
def test_header_account_not_used_without_header_code() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
resolve_levels(rows, None, never_expires(), header_account=False)
def test_header_account_not_used_with_account_rows() -> None:
rows = numbered(("60.01", {}), ("ООО А", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "account_pattern"
assert strategy_header_account(rows) is None
def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> None:
"""Две соседние строки с равными суммами при счёте шапки — плоский список, а не вложенность по суммам."""
same = money("100.00", None, "50.00", None, "150.00", None)
rows = numbered(("ООО Ромашка", {"sums": same}), ("ООО Лютик", {"sums": same}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0]
assert set(levels.parent.values()) == {None}

View File

@ -0,0 +1,314 @@
"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк."""
from pathlib import Path
import pytest
from gridbuilder import L, M, sheet_from_rows
from src.contract.result import OsvDocument, OsvRow
from src.parser import get_parser
from src.parser.deadline import Deadline
from src.parser.osv import OsvParser
from src.parser.rules import Rules, load_rules
TIMEOUT_SECONDS = 60
HEADER_ACCOUNT_CODE = "OSV_HIERARCHY_BY_HEADER_ACCOUNT"
NU_ROWS = 3 # две строки НУ под контрагентами и итоговая НУ после «Итого»
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _table_head() -> list[list[object]]:
return [
["Субконто", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
def _parse(rules: Rules, rows: list[list[object]]) -> tuple[OsvDocument, list[str]]:
result = OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS))
return result.document, [note.code for note in result.notes]
def test_flat_list_s40(rules: Rules) -> None:
"""Образец s40: счёт «по счету: 76.Н.1», вид «Контрагенты», организация ниже периода, данные без оформления."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76.Н.1"],
["Контрагенты"],
["за 2025 г."],
['ООО "Товарное хозяйство"'],
[],
["Единица измерения: рубль (код по ОКЕИ 383)"],
[],
*_table_head(),
["Август - Агро ООО", 1000, None, 500, 300, 1200, None],
["АВИС ООО", None, 200, 100, 400, None, 500],
["Агроаспект ООО", None, None, 50, 50, None, None],
["Итого развернутое", 1000, 200, None, None, 1200, 500],
["Итого", 800, None, 650, 750, 700, None],
]
document, codes = _parse(rules, rows)
assert document.status == "done"
assert HEADER_ACCOUNT_CODE in codes
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.row_value for item in data] == ["Август - Агро ООО", "АВИС ООО", "Агроаспект ООО"]
assert all(item.level == 0 and item.parent_row_number is None for item in data)
assert {item.account_code_raw for item in data} == {"76.Н.1"}
assert document.header.organization_name == 'ООО "Товарное хозяйство"'
def test_flat_list_s41(rules: Rules) -> None:
"""Образец s41 (R-10): схема [ACCOUNT, COUNTERPARTY («Организации»)], счёт шапки «60»."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 60"],
["Организации"],
["за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"],
[],
*_table_head(),
["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None],
["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764],
["Итого развернутое", 820, 2764, None, None, 829, 2764],
["Итого", None, 1944, 5000, 4991, None, 1935],
]
document, _ = _parse(rules, rows)
assert document.status == "done"
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.account_code_raw for item in data] == ["60", "60"]
assert [item.group_type for item in data] == ["COUNTERPARTY", "COUNTERPARTY"]
def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None:
"""Счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки."""
rows: list[list[object]] = [
["ООО Зерновая"],
["Оборотно-сальдовая ведомость по счету 60"],
["Период: 9 месяцев 2025 г."],
[],
*_table_head(),
[L(0), "ООО А", 100, None, 50, 30, 120, None],
[L(1), "Договор 1", 100, None, 50, 30, 120, None],
[L(0), "Итого", 100, None, 50, 30, 120, None],
]
document, codes = _parse(rules, rows)
assert "OSV_HIERARCHY_BY_OUTLINE" in codes
assert HEADER_ACCOUNT_CODE not in codes
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.account_code_raw for item in data] == ["60", "60"]
# --- Контрагенты расчётных счетов без названия вида (Task 5, R-6, R-10, R-17) ---
BY_ACCOUNT_WARNING = (
"вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте"
)
def _parse_synthetic(synthetic_dir: Path, name: str) -> tuple[OsvDocument, list[str], str]:
outcome = get_parser().parse((synthetic_dir / name).read_bytes(), "XLSX")
assert outcome.error is None, outcome.error
(document,) = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)]
return document, [note.message for note in document.warnings], document.status
def _data_rows(document: OsvDocument) -> list[OsvRow]:
return [item for item in document.rows if item.group_type != "TOTAL"]
@pytest.mark.fixtures
def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None:
document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx")
kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"}
assert set(kids) == {"ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'}
assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"}
notes = [note for note in document.warnings if note.code == "OSV_ROWS"]
assert [note.message for note in notes] == [BY_ACCOUNT_WARNING]
assert status == "done"
assert messages.count(BY_ACCOUNT_WARNING) == 1
@pytest.mark.fixtures
def test_s44_materials_stay_other(synthetic_dir: Path) -> None:
document, messages, _ = _parse_synthetic(synthetic_dir, "s44_subkonto_unnamed_10.xlsx")
kids = [item for item in _data_rows(document) if item.row_value in {"Мука пшеничная", "Сахар-песок"}]
assert [item.row_value for item in kids] == ["Мука пшеничная", "Сахар-песок"]
assert {item.group_type for item in kids} == {"OTHER"}
assert not any("вид субконто в шапке не назван" in message for message in messages)
@pytest.mark.fixtures
def test_s41_flat_counterparties(synthetic_dir: Path) -> None:
document, messages, _ = _parse_synthetic(synthetic_dir, "s41_flat_chart_of_accounts.xlsx")
data = _data_rows(document)
assert {item.group_type for item in data} == {"COUNTERPARTY"}
assert {item.account_code_raw for item in data} == {"60"}
assert not any("вид субконто в шапке не назван" in message for message in messages)
def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None:
"""T4.2: плоский список под счётом шапки с испорченной суммой «Итого» — needs_check, не done."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76.Н.1"],
["Контрагенты"],
["за 2025 г."],
[],
*_table_head(),
["Август - Агро ООО", 1000, None, 500, 300, 1200, None],
["АВИС ООО", None, 200, 100, 400, None, 500],
["Итого", 800, None, 650, 700, 1200, 500],
]
document, _ = _parse(rules, rows)
assert document.status == "needs_check"
assert document.checks is not None
assert document.checks.total_row_matches_root is False
# T4.1: без корня ACCOUNT текст говорит о сумме строк уровня 0, а не о «корневом счёте нет»
messages = [note.message for note in document.warnings]
assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages
assert not any("корневого счёта нет" in message for message in messages)
def test_flat_list_with_indicator_column_and_second_total(rules: Rules) -> None:
"""Стык треков A и B (#58, #59): колонка «Показатели», «Итого развернутое» перед «Итого» и строка НУ после неё.
Показатели не становятся контрагентами (R-6, R-18) и не сбивают сверку «Итого» (она идёт по основной «Итого» с
меткой БУ); тождество строки у них проверяется, как у всех (спека трека A) — строки НУ его соблюдают.
"""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76"],
["Контрагенты"],
["за 2025 г."],
['ООО "Тест"'],
[],
[
"Субконто",
"Показатели",
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
[None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None],
[None, "НУ", 900, None, 400, 300, 1000, None],
["АВИС ООО", "БУ", None, 200, 100, 400, None, 500],
[None, "НУ", None, 150, 100, 350, None, 400],
["Итого развернутое", "БУ", 1000, 200, 600, 700, 1200, 500],
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
document, codes = _parse(rules, rows)
assert document.status == "done", [note.message for note in document.warnings]
assert document.checks is not None
assert document.checks.total_row_matches_root is True
indicators = [item for item in document.rows if item.data_type == "NU"]
assert len(indicators) == NU_ROWS
assert {item.counterparty_name for item in indicators} == {None}
assert {item.group_type for item in indicators if item.group_type != "TOTAL"} == {"OTHER"}
counterparties = [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"]
assert counterparties == ["Август - Агро ООО", "АВИС ООО"]
assert [item.group_type for item in document.rows if item.group_type == "TOTAL" and item.data_type == "BU"] == [
"TOTAL",
"TOTAL",
]
def _indicator_flat_sheet(tail: list[list[object]]) -> list[list[object]]:
"""Плоская форма 1С с колонкой «Показатели»: два контрагента с БУ/НУ, дальше — итоговый блок `tail`."""
return [
[],
["Оборотно-сальдовая ведомость по счету: 76"],
["Контрагенты"],
["за 2025 г."],
['ООО "Тест"'],
[],
[
"Субконто",
"Показатели",
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
[None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Август - Агро ООО", "БУ", 1000, None, 500, 300, 1200, None],
[None, "НУ", 900, None, 400, 300, 1000, None],
["АВИС ООО", "БУ", None, 200, 100, 400, None, 500],
[None, "НУ", None, 150, 100, 350, None, 400],
*tail,
]
def test_indicator_under_second_total_before_total_joins_total_block(rules: Rules) -> None:
"""I-6, R-21: НУ без объединения под «Итого развернутое» перед «Итого» — итоговый блок, не фиктивный контрагент."""
rows = _indicator_flat_sheet(
[
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
assert document.status == "done", [note.message for note in document.warnings]
assert [item.row_value for item in document.rows if item.group_type == "COUNTERPARTY"] == [
"Август - Агро ООО",
"АВИС ООО",
]
secondary = next(item for item in document.rows if item.row_value == "Итого развернутое")
assert secondary.group_type == "TOTAL"
assert document.checks is not None
assert document.checks.balance_identity_violations == []
def test_indicator_under_last_data_row_is_not_pulled_into_total_block(rules: Rules) -> None:
"""R-21, негативный: НУ под последней строкой данных перед «Итого» остаётся показателем этой строки."""
rows = _indicator_flat_sheet(
[
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
)
document, _ = _parse(rules, rows)
nu_under_data = [item for item in document.rows if item.data_type == "NU" and item.group_type != "TOTAL"]
assert len(nu_under_data) == NU_ROWS - 1
assert document.status == "done", [note.message for note in document.warnings]
@pytest.mark.parametrize("secondary_first", [True, False])
def test_indicators_of_second_total_without_turnovers_skip_identity(rules: Rules, secondary_first: bool) -> None:
"""I-7, R-21: «Итого развернутое» без оборотов со своим НУ не даёт нарушений тождества (до и после «Итого»)."""
secondary: list[list[object]] = [
["Итого развернутое", "БУ", 1000, 200, None, None, 1200, 500],
[None, "НУ", 900, 150, None, None, 1000, 400],
]
main: list[list[object]] = [
["Итого", "БУ", 1000, 200, 600, 700, 1200, 500],
[None, "НУ", 900, 150, 500, 650, 1000, 400],
]
document, _ = _parse(rules, _indicator_flat_sheet([*secondary, *main] if secondary_first else [*main, *secondary]))
assert document.status == "done", [note.message for note in document.warnings]
assert document.checks is not None
assert document.checks.balance_identity_violations == []

View File

@ -359,12 +359,32 @@ 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_single_organization_is_own_organization_not_counterparty(rules: Rules) -> None:
"""R-17: «Организация» (единственное число) — собственная организация учёта, не вид субконто «Организации»."""
assert rules.groups.known("Организация") is None
assert rules.groups.classify("Организация") == "OTHER"
@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "В валюте", "в валюте CNY"])
def test_currency_rows_are_known_other_groups(rules: Rules, text: str) -> None:
"""R-18: строки валюты словарь групп знает как OTHER — по плану счетов они контрагентами не становятся."""
assert rules.groups.known(text) == "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)))
@ -698,3 +718,44 @@ def test_v_valyute_row_is_currency(rules: Rules) -> None:
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"]) @pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
def test_indicator_service_column(rules: Rules, title: str) -> None: def test_indicator_service_column(rules: Rules, title: str) -> None:
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"]) assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-6) ---
@pytest.mark.parametrize(
("code", "expected"),
[
("76", True),
("76.2", True),
("76.Н.1", True),
("60.01", True),
("60", True),
(" 62.01 ", True),
("760", False),
("600", False),
("10.01", False),
("71", False),
("73.01", False),
("", False),
(None, False),
],
)
def test_counterparty_accounts_match_code_and_subaccounts(rules: Rules, code: str | None, expected: bool) -> None:
assert rules.groups.is_counterparty_account(code) is expected
def test_counterparty_accounts_list_is_data(rules: Rules) -> None:
assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"]
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_groups.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["counterparty_accounts"] = [bad]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*counterparty_accounts"):
load_rules(target)

View File

@ -0,0 +1,217 @@
# ОСВ 1С в плоской форме (AURORA-1661, структура) Implementation Plan
> **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:** ОСВ 1С по одному счёту в плоской форме («по счету: X», колонка «Субконто», без строки счёта, «Итого развернутое» перед «Итого») разбирается без `INVALID_FORMAT`, со счётом и организацией из шапки, а контрагенты расчётных счетов без названия вида получают `COUNTERPARTY`.
**Architecture:** Изменения в конвейере листа ОСВ tabular-parser: шапка (`header.py`) — код счёта с двоеточием, строка вида субконто, «План счетов»; итоговый блок (`classify.collect_body_rows`, `checks._total`); новая стратегия каскада `header_account` (`hierarchy.py`) и счёт шапки у корня ветки (`classify_rows`); правило расчётных счетов — данные `osv_groups.yaml`. Контракт результата не меняется.
**Tech Stack:** Python 3.11, pydantic v2, openpyxl, pytest; OpenSpec 1.13.
**Spec:** `openspec/changes/osv-flat-form/` (proposal.md, design.md, specs/osv-parsing/spec.md)
**Ветка:** `feat/59-osv-flat-form` во всех трёх worktree: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/{tabular-parser,fixtures,docs}`. Issue — RSHB/arm-tabular-parser#59. Jira — AURORA-1661 (структурная часть).
## Global Constraints
- Каждая shell-команда начинается с `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/<repo> &&`; git — только `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/<repo>`; в основные checkout'ы (`/Users/andrey.epifancev/projects/awp-ca/tabular-parser` и т. п., ветка main) не заходить и не писать. Хук отказал — стоп, вернуться к контроллеру с текстом отказа.
- Код Python правится только Edit/Write, не `sed -i`/heredoc через Bash.
- Нет координат в `src/parser/**` (AST-линтер `tests/unit/parser/test_no_coordinates.py`): ни букв колонок, ни номеров строк, ни имён листов. Новые синонимы и списки — данные `api/rules/*.yaml`, не `if` в коде.
- Деньги — `Decimal`, `float` нет. `needs_check` — только по контрольным суммам; структуру под сходимость не подгонять.
- Контракт (`api/contracts/`, `src/contract/`) не меняется; `CONTRACT_VERSION` прежний.
- Синтетика этого плана — `s40`–`s44` (номера `s33`–`s39` — у параллельного трека A, не занимать).
- Golden (`../fixtures/golden/expected_osv_parse/`) правится только `make golden-update` с просмотром diff, коммит — в репозиторий fixtures отдельно от кода, сообщение с `(tabular-parser#59)`. Изменение снимка реального файла, не объяснённое задачей, — стоп и вопрос контроллеру.
- Реальные файлы контура (`/Users/andrey.epifancev/projects/awp-ca/result_dir.zip`, `/Users/andrey.epifancev/projects/awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx`) в git не кладутся: только распаковка во временный каталог вне репозиториев для сверки.
- Трек A (`.wt/osv-indicators/`, AURORA-1613 + валюта 1661) владеет путём `data_type` (`rules/data_types.yaml`, `rules.RowDataType`, `classify.row_data_type`, `checks._INDICATOR_DATA_TYPES`, колонка «Показатели», умолчание BU) — эти места не трогать.
- Коммиты — по-русски, с `(#59)` в теме и строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Проверка задачи: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser && make check` зелёный (≈95 с; включает fixtures-тесты).
## Review Focus
1. «по счету:» без кода после двоеточия (код в соседней ячейке или нет вовсе) — `account_code_raw` пуст, работает фолбэк OQ-134 из строки таблицы с прежним предупреждением; тест в задаче 2.
2. Название и период в одной строке («…по счету 60 за 1 квартал 2025 г.») — строки вида субконто нет, организация по прежнему правилу (первая не-метка); тест в задаче 2.
3. На листе только «Итого развернутое», без обычной «Итого» — она единственная итоговая и сверяется как «Итого»; тест в задаче 3.
4. Счёт в шапке, строк-счетов нет, но есть outline (контрагент → договоры) — побеждает `outline`, `header_account` не используется, строки всё равно наследуют счёт шапки; тест в задаче 4.
5. Счёт ветки «760» или «600» при записи `76`/`60` в `counterparty_accounts` — не расчётный, строки OTHER; тест в задаче 5.
---
### Task 1: Синтетика s40–s44 и снимки текущего поведения
**Files:**
- Modify: `api/tests/synthetic/generate.py` (функции `_s40`…`_s44`, записи реестра `FILES`, docstring `main` — «s01…s44»)
- Create: `../fixtures/osv/synthetic/s40_flat_header_account.xlsx`, `s41_flat_chart_of_accounts.xlsx`, `s42_subaccounts_totals_reversed.xlsx`, `s43_subkonto_unnamed_60.xlsx`, `s44_subkonto_unnamed_10.xlsx`
- Create: снимки `../fixtures/golden/expected_osv_parse/synthetic/s40…s44.json` (`make golden-update`)
**Interfaces:**
- Produces: пять файлов синтетики; ожидания по ним задачи 2–5 проверяют разбором через `OsvParser`/`get_parser()`.
Содержимое листов (все — один лист, колонка A — текст, B–G — суммы «Сальдо на начало / Обороты за период / Сальдо на конец периода» × «Дебет | Кредит»; заголовок групп — объединения B:C, D:E, F:G; числа — `Decimal`; пусто — нет ячейки):
- `s40`: r2 «Оборотно-сальдовая ведомость по счету: 76.Н.1», r3 «Контрагенты», r4 «за 2025 г.», r5 «ООО "Товарное хозяйство"», r7 «Единица измерения: рубль (код по ОКЕИ 383)», r9 «Субконто» + группы, r10 подзаголовки; данные без outline и отступов: «Август - Агро ООО» 1000.00 | — | 500.00 | 300.00 | 1200.00 | —; «АВИС ООО» — | 200.00 | 100.00 | 400.00 | — | 500.00; «Агроаспект ООО» — | — | 50.00 | 50.00 | — | —; «Итого развернутое» 1000.00 | 200.00 | — | — | 1200.00 | 500.00; «Итого» 800.00 | — | 650.00 | 750.00 | 700.00 | —.
- `s41`: r2 «Оборотно-сальдовая ведомость по счету: 60», r3 «Организации», r4 «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива», r5 «Субконто» + группы, r6 подзаголовки; «Автодор-Платные Дороги ООО» 820.21 | — | 5000.00 | 4991.00 | 829.21 | —; «АГРОПРОФИ ООО» — | 2763.79 | — | — | — | 2763.79; «Итого развернутое» 820.21 | 2763.79 | — | — | 829.21 | 2763.79; «Итого» — | 1943.58 | 5000.00 | 4991.00 | — | 1934.58.
- `s42`: r2 «Оборотно-сальдовая ведомость по счету: 76», r3 «за 2025 г.», r4 «ООО "Товарное хозяйство"», r6 «Единица измерения: рубль (код по ОКЕИ 383)», r8 «Субконто» + группы, r9 подзаголовки; «76.2» 1000.00 | — | — | 1000.00 | — | —; «76.5» — | 300.00 | 700.00 | 600.00 | — | 200.00; «76.Н» — | — | 5000.00 | 5000.00 | — | —; «76.Н.1» — | — | 5000.00 | 5000.00 | — | —; «Итого развернутое» 1000.00 | 300.00 | — | — | — | 200.00; «Итого» 700.00 | — | 5700.00 | 6600.00 | — | 200.00.
- `s43`: r1 «ООО "Октябрьское"», r2 «Оборотно-сальдовая ведомость по счету 60», r3 «Период: 9 месяцев 2024 г.», r5 «Субконто» + группы, r6 подзаголовки; «60.01» 34618000.00 | — | 4236500.00 | 38854500.00 | — | —; отступ 1: «ИП Иванов А.А.» 34618000.00 | — | — | 34618000.00 | — | —; отступ 1: «ООО "ЭСТЕТ ОМСК"» — | — | 4236500.00 | 4236500.00 | — | —; «Итого» и затем «Итого развернутое» — обе 34618000.00 | — | 4236500.00 | 38854500.00 | — | —.
- `s44`: как `s43`, но счёт 10: r2 «…по счету 10», «10.01» и строки «Мука пшеничная», «Сахар-песок» с теми же суммами.
- [ ] **Step 1: Добавить `_s40`…`_s44` и записи реестра**, по образцу `_s31`/`_s32` (листы через `SheetSpec`/`put`, отступ — `CellSpec(..., indent=1)`).
- [ ] **Step 2: Сгенерировать файлы**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser/api && .venv/bin/python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (если `.venv` нет — `cd .. && make venv`).
Expected: в выводе пути `s40…s44`; `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures status` — пять новых файлов, прежние файлы синтетики без изменений (генератор детерминирован).
- [ ] **Step 3: Снимки текущего поведения**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser && make golden-update`
Expected: новые снимки только `synthetic/s40…s44.json`; у `s40`, `s41` — `status: "error"`, `INVALID_FORMAT` «иерархия строк не определена»; `s42` — `needs_check`, «Итого развернутое» — OTHER; `s43`, `s44` — контрагенты/материалы OTHER. Других изменений снимков нет.
- [ ] **Step 4: `make check` зелёный**
- [ ] **Step 5: Commit (два репозитория)**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures add osv/synthetic golden/expected_osv_parse/synthetic
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/fixtures commit -m "Синтетика s40–s44: плоская форма ОСВ 1С, снимки текущего поведения (tabular-parser#59)"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser add api/tests/synthetic/generate.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser commit -m "test: синтетика s40–s44 — плоская форма ОСВ 1С (#59)"
```
---
### Task 2: Шапка — код счёта с двоеточием, строка вида субконто, «План счетов»
**Files:**
- Modify: `api/rules/osv_headers.yaml` (`header_markers.chart_of_accounts: ['\bплан\s+счетов\b']`)
- Modify: `api/src/parser/rules.py` (`HeaderMarker` + `"chart_of_accounts"`; `OsvGroupRules.known`)
- Modify: `api/src/parser/header.py` (`_account_code`, `_organization`, новый `_kind_row_cells`, `_variant1_groups`/`build_group_levels`)
- Test: `api/tests/unit/parser/test_header.py`, `api/tests/unit/parser/test_rules.py`
**Interfaces:**
- Produces: `OsvGroupRules.known(text: str) -> GroupType | None` — тип по явной записи `mapping`, `None` для `default`; `header._kind_row_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]` — ячейки строго между строкой ячейки с меткой `account` и строкой периода (`_find_period`), если первая выше; иначе `[]`.
- [ ] **Step 1: Тесты (падают)** — в `test_header.py` через `sheet_from_rows`:
- `test_account_code_after_colon`: «Оборотно-сальдовая ведомость по счету: 76.Н.1» → `account_code_raw == "76.Н.1"`, `ACCOUNT_NOT_FOUND_WARNING` нет в `warnings`.
- `test_colon_without_code_falls_back_to_table_row` (Review Focus 1): «…по счету:» без кода → `account_code_raw == ""` и `ACCOUNT_NOT_FOUND_WARNING` в `warnings` (фолбэк делает `osv._with_table_account`).
- `test_kind_row_between_title_and_period_is_not_organization`: строки «…по счету: 76», «Контрагенты», «за 2025 г.», «ООО "Товарное хозяйство"» → `organization_name == 'ООО "Товарное хозяйство"'`.
- `test_organization_from_chart_of_accounts_tail`: «…по счету: 60», «Организации», «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» → `organization_name == "ООО Хреновская Нива"`, `period_quarter == "Q2"`, `period_year == 2026`.
- `test_title_and_period_in_one_row_keep_old_organization_rule` (Review Focus 2): существующая раскладка `_variant1` (`org` выше названия с периодом) → `organization_name == 'АО "Ромашка"'`.
- `test_known_kind_row_extends_single_account_schema`: шапка как у `s40`, заголовок «Субконто» → `build_group_schema(...)[0] == [ACCOUNT("Субконто"), COUNTERPARTY("Контрагенты")]`.
- `test_unknown_kind_row_is_not_added_to_schema`: строка «Материалы» → схема `[ACCOUNT("Субконто")]`.
- `test_organizations_kind_row_is_counterparty` (R-10): строка «Организации» → схема `[ACCOUNT("Субконто"), COUNTERPARTY("Организации")]`; в `osv_groups.yaml` добавить `['^организаци', COUNTERPARTY]` после `^контрагент`.
- в `test_rules.py`: `test_known_group_is_none_for_default` — `rules.groups.known("Контрагенты") == "COUNTERPARTY"`, `known("Материалы") is None`, `known("Организации") == "COUNTERPARTY"`, `known("Номенклатура") == "OTHER"`.
- [ ] **Step 2: Запуск** — `cd …/tabular-parser/api && .venv/bin/pytest tests/unit/parser/test_header.py tests/unit/parser/test_rules.py -q` → новые тесты FAIL.
- [ ] **Step 3: Реализация.**
- `_account_code`: хвост `_tail(...)` очистить от ведущих `:` и пробелов перед `_ACCOUNT_CODE.match`.
- `_organization`: сначала хвост метки `chart_of_accounts` в любой ячейке шапки (текст после совпадения, `strip(" ,;:")`, непустой); затем первая не-метка, исключая ячейки `_kind_row_cells`; затем `_title_tail_organization`. Ячейка периода, содержащая «План счетов», остаётся периодом (`_cut_period` режет на «г.»).
- `_is_structural_marker`: метка `chart_of_accounts` — структурная.
- Схема: если `_variant1_groups` дал ровно одну ACCOUNT, добавить по элементу текста `_kind_row_cells` (через запятую, `strip`), для которого `rules.groups.known(item)` не `None`: `_group(known, item)`; вариант 2 не меняется.
- [ ] **Step 4: Тесты PASS**, затем `make golden-update`: ожидаемо меняются только `synthetic/s40` и `s41` (если они ещё `error` — без изменений) и `s42` (`account_code_raw` «76» вместо «76.2», без предупреждения «взят из первой строки-счёта»). Изменение реального снимка — стоп (Global Constraints).
- [ ] **Step 5: `make check` зелёный; commit** — tabular-parser: `feat(osv): код счёта «по счету: X», строка вида субконто, «План счетов» в шапке (#59)`; fixtures (если снимки изменились): `Снимки s40–s42: шапка плоской формы (tabular-parser#59)`.
---
### Task 3: «Итого развернутое» перед «Итого»
**Files:**
- Modify: `api/src/parser/classify.py` (`collect_body_rows`, новый `_leading_totals`)
- Modify: `api/src/parser/checks.py` (`_total`, `total_matches_root`, `run_checks`)
- Modify: `api/src/parser/osv.py` (передать `rules.headers.total_row_secondary` в `run_checks`)
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_checks.py`
**Interfaces:**
- Produces: `collect_body_rows(...)` — тот же кортеж `(body, total_row, warnings)`, но `total_row` — **первая** строка итогового блока (вторые итоговые строки непосредственно перед основной «Итого» входят в блок); `run_checks(rows, exempt=None, *, secondary_totals: Sequence[Pattern] = ()) -> tuple[OsvChecks, list[OsvNote]]`; `total_matches_root(rows, secondary_totals: Sequence[Pattern] = ()) -> bool`.
- [ ] **Step 1: Тесты (падают)**
- `test_secondary_total_before_main_total_is_in_total_block` (`test_classify.py`): лист как `s42` → `total_row` = номер строки «Итого развернутое»; после `classify_rows` обе строки — `TOTAL`, `level == 0`, `parent is None`.
- `test_only_secondary_total_is_the_total` (Review Focus 3): на листе только «Итого развернутое» → она `TOTAL`, `total_row` — её номер.
- `test_total_check_uses_main_total_when_secondary_comes_first` (`test_checks.py`): строки TOTAL в порядке «Итого развернутое», «Итого»; суммы «Итого» = корень, «Итого развернутое» — нет → `total_matches_root(rows, secondary) is True`; без `secondary` (прежний вызов) — `False`.
- Разбор `s42` через `get_parser()`: `status == "done"`, `checks.parent_sum_violations == []`, `checks.balance_identity_violations == []`.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация.** `_leading_totals(sheet, header, rows, total, rules) -> int` — идти от индекса основной «Итого» назад, пока текст строки (колонка текста) матчит `total_row_secondary`; вернуть номер самой верхней (или `total`). В `collect_body_rows` вернуть его как `total_row`; хвост — по-прежнему от последней из вторых строк после основной. `checks._total(rows, secondary)` — первая TOTAL, чей `body.text` не матчит ни один паттерн `secondary`, иначе первая TOTAL.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `synthetic/s42.json` (обе итоговые строки TOTAL, `status: done`); реальные снимки (пакет ДКБ с «Итого развернутое» после «Итого») не меняются.
- [ ] **Step 5: `make check`; commit** — `fix(osv): «Итого развернутое» перед «Итого» — итоговая строка, сверка по основной «Итого» (#59)`; fixtures — `Снимок s42: «Итого развернутое» перед «Итого» (tabular-parser#59)`.
---
### Task 4: Плоский список под счётом шапки
**Files:**
- Modify: `api/src/parser/hierarchy.py` (`strategy_header_account`, `resolve_levels`)
- Modify: `api/src/parser/osv_warnings.py` (`HIERARCHY_TEXTS["header_account"] = "плоский список под счётом шапки (header_account)"`)
- Modify: `api/src/parser/classify.py` (`classify_rows(..., header_account: str | None = None)`)
- Modify: `api/src/parser/checks.py` (`total_matches_root` без корня ACCOUNT)
- Modify: `api/src/parser/osv.py` (передать `header_account` в `resolve_levels` и `classify_rows`)
- Test: `api/tests/unit/parser/test_hierarchy.py`, `api/tests/unit/parser/test_checks.py`, Create `api/tests/unit/parser/test_osv_flat_form.py`
**Interfaces:**
- Consumes: `collect_body_rows` и `run_checks(..., secondary_totals=...)` задачи 3.
- Produces: `strategy_header_account(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None` — `{row: 0}` для всех строк, `None`, если хоть одна `is_account`; `resolve_levels(rows, total_row, deadline, *, header_account: bool = False) -> Levels` — `header_account` пробуется после `account_pattern` и перед `sums`, только при `header_account=True`; `classify_rows(..., *, inns=None, unpaired=frozenset(), header_account: str | None = None)` — корневая ветка `_Branch(header_account, 0)`.
- [ ] **Step 1: Тесты (падают)**
- `test_header_account_strategy_flat_rows` (`test_hierarchy.py`): три строки без outline/отступа/счёта, `header_account=True` → `strategy == "header_account"`, все уровни 0, родители `None`.
- `test_header_account_not_used_without_header_code`: те же строки, `header_account=False` → `InvalidFormatError` (прежнее поведение).
- `test_header_account_not_used_with_account_rows`: строки «60.01», «ООО А» без оформления, `header_account=True` → `account_pattern`.
- `test_total_without_account_root_matches_level_zero_sum` (`test_checks.py`): три корня OTHER/COUNTERPARTY без ACCOUNT, «Итого» = их сумма (сальдо «Итого» свёрнуто нетто) → `True`; одна сумма испорчена на 1 → `False`; только «Итого» с нулями → `True` (R-13 прежний).
- `test_osv_flat_form.py::test_flat_list_s40` (разбор `s40` через `get_parser()` или `OsvParser.parse_sheet` на листе из `sheet_from_rows`): `status == "done"`, предупреждение `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, у каждой строки данных `level == 0`, `parent_row_number is None`, `account_code_raw == "76.Н.1"`, `organization_name == 'ООО "Товарное хозяйство"'`.
- `test_flat_list_s41`: `status == "done"`, `account_code_raw` строк `"60"`.
- `test_outline_wins_over_header_account_and_rows_inherit_header_code` (Review Focus 4): счёт в шапке «60», строк-счетов нет, outline: «ООО А» (0) → «Договор 1» (1) → `strategy == "outline"`, у обеих строк `account_code_raw == "60"`.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация** по Interfaces; в `osv.py`: `resolve_levels(body, total_row, deadline, header_account=bool(doc.account_code_raw))`, `classify_rows(..., header_account=doc.account_code_raw or None)`. `total_matches_root`: нет корня ACCOUNT — если `_level_zero(rows)` не пуст, `_sums_match(_values(total), _sum_of(roots))`, иначе прежняя проверка нулей.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `s40`, `s41` (`done`, `header_account`); реальные снимки — только если у файла есть строки до первой строки-счёта (получат `account_code_raw` шапки, R-4) — каждое такое изменение перечислить в отчёте; иные изменения — стоп.
- [ ] **Step 5: `make check`; commit** — `feat(osv): стратегия header_account — плоский список под счётом шапки (#59)`; fixtures — `Снимки: плоский список под счётом шапки (tabular-parser#59)`.
---
### Task 5: Контрагенты расчётных счетов без названия вида
> Перед задачей: если трек A (`feat/*` в `.wt/osv-indicators/tabular-parser`, AURORA-1613) уже слит в main — `git -C …/tabular-parser fetch origin && git -C …/tabular-parser rebase origin/main`, `make golden-update`, разрешить конфликты в `classify._classify_row` (трек A меняет определение `data_type` в той же функции; сохранить обе правки). Не слит — работать как есть.
**Files:**
- Modify: `api/rules/osv_groups.yaml` (`counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80']` с комментарием про AURORA-1661, OQ-158 и R-9 (71/73 исключены))
- Modify: `api/src/parser/rules.py` (`OsvGroupRules.counterparty_accounts: list[str] = []`, проверка кодов как у `OsvCheckRules`, `is_counterparty_account`; общий хелпер сопоставления «код или его субсчёт» для обоих классов)
- Modify: `api/src/parser/classify.py` (`_classify_row`, `_Kind`, `ClassifiedRow.counterparty_by_account: bool = False`)
- Modify: `api/src/parser/osv.py` (заметка `OSV_ROWS`)
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_rules.py`, `api/tests/unit/parser/test_osv_flat_form.py`
**Interfaces:**
- Consumes: `classify_rows(..., header_account=...)` задачи 4.
- Produces: `OsvGroupRules.is_counterparty_account(account_code: str | None) -> bool` (`"76"` → `76`, `76.2`, `76.Н.1`; не `760`); `ClassifiedRow.counterparty_by_account` — строка получила COUNTERPARTY по плану счетов.
- [ ] **Step 1: Тесты (падают)**
- `test_rules.py::test_counterparty_accounts_match_code_and_subaccounts`: `is_counterparty_account("76.Н.1")`, `("60.01")` — `True`; `("760")`, `("600")`, `("10.01")`, `(None)` — `False` (Review Focus 5).
- `test_classify.py::test_unnamed_first_level_on_settlement_account_is_counterparty`: схема `[ACCOUNT("Субконто")]`, «60.01» → «ИП Иванов А.А.» (уровень 1) → COUNTERPARTY, `group_name is None`, `counterparty_name == "ИП Иванов А.А."`, `counterparty_by_account is True`.
- `test_unnamed_second_level_stays_other`: под таким контрагентом строка уровня 2 → OTHER.
- `test_named_other_group_is_not_reclassified`: схема `[ACCOUNT, OTHER("Работники организаций")]` на 71 → OTHER.
- `test_non_settlement_account_stays_other`: счёт «10.01» → OTHER, `counterparty_by_account is False`.
- `test_osv_flat_form.py::test_s43_counterparties_with_warning`: разбор `s43` → строки «ИП Иванов А.А.», «ООО "ЭСТЕТ ОМСК"» — COUNTERPARTY; в `warnings` ровно одно `OSV_ROWS` с текстом «вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте»; `status == "done"`.
- `test_s44_materials_stay_other`: разбор `s44` → строки OTHER, предупреждения «вид субконто…» нет.
- `test_s41_flat_counterparties`: разбор `s41` → строки данных COUNTERPARTY (по схеме с видом «Организации», R-10), `account_code_raw == "60"`, предупреждения «вид субконто…» нет.
- [ ] **Step 2: Запуск — FAIL.**
- [ ] **Step 3: Реализация.** В `_classify_row` после `_schema_group`: если `position == 1`, `position >= len(schema)` и `rules.groups.is_counterparty_account(code)` — COUNTERPARTY без имени, ИНН — по общей ветке COUNTERPARTY (колонка, строка ИНН, хвост названия); флаг `counterparty_by_account`. В `osv.py` — по одной заметке `OsvNote(OSV_ROWS, …)` на каждый различный `account_code_raw` строк с флагом, в порядке листа.
- [ ] **Step 4: PASS; `make golden-update`** — ожидаемо `s41`, `s43`; реальные снимки, где схема — одна ACCOUNT на расчётном счёте с не-счётными строками (пакет ДКБ `osv-dkb/**`), — OTHER → COUNTERPARTY и новое `OSV_ROWS`: перечислить файлы в отчёте; статусы снимков не должны меняться (если меняются — стоп).
- [ ] **Step 5: `make check`; commit** — `feat(osv): контрагенты расчётных счетов без названия вида — по плану счетов (#59)`; fixtures — `Снимки: контрагенты расчётных счетов по плану счетов (tabular-parser#59)`.
---
### Task 6: Правила, открытые вопросы, README, приёмка на реальных файлах
**Files:**
- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/docs/context/05-domain-rules.md` (§1.1, §1.3, §1.4)
- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/docs/context/07-open-questions.md`
- Modify: `README.md` (корень tabular-parser, раздел «Парсер ОСВ»: каскад стратегий + `header_account`), `CLAUDE.md` tabular-parser (строка каскада в «Модули» — `outline → indent → account_pattern → header_account → sums`)
- Modify: `openspec/changes/osv-flat-form/tasks.md` (отметить пункты), `design.md` (раздел «Decisions made without discussion» — дописать рулинги исполнения, если были)
- [ ] **Step 1: §1 правил (docs).** §1.1: код счёта после «по счету» — и через двоеточие; счёт шапки важнее строки таблицы; строка вида субконто между названием и периодом — не организация, известные виды — группировки после ACCOUNT; организация из «План счетов <орг>»; строка таблицы «Нормализация значений шапки» — без изменений. §1.3: стратегия `header_account` (условия, место в каскаде); счёт шапки у строк до первой строки-счёта; правило расчётных счетов (список — данные `osv_groups.yaml`, позиция 1, без имени, предупреждение; риск OQ-158). §1.4: «Итого развернутое» в любом порядке; «Итого» без корневой ACCOUNT = сумма уровня 0. Ссылки — AURORA-1661, tabular-parser#59, решения владельца 16.10/19.10.2026.
- [ ] **Step 2: Открытые вопросы (docs)** — следующие свободные номера OQ на момент правки (трек A может занять номера раньше — сверить с `origin/main` docs): «Учредители» на 80 (именованная OTHER, гудвилл §4 п. 2); сузить список расчётных счетов (71, 73); «Организации» в строке вида субконто — что это за вид в 1С. Каждый — «открыт», с допущением принятого поведения (R-8 остаётся OTHER, R-9 71/73 исключены, R-10 синоним COUNTERPARTY).
- [ ] **Step 3: Приёмка на реальных файлах контура (без коммита данных).**
Run:
```bash
T=$(mktemp -d) && cd "$T" && unzip -q /Users/andrey.epifancev/projects/awp-ca/result_dir.zip 'files/26e4c90d*' 'files/638ecb3d*' 'files/f2e0e04e*' 'files/7e49f8b5*' 'files/45e06f7d*' 'files/cb7e6eb0*' 'files/270b41da*' 'files/738bfcf5*' 'files/8c7e95d9*' && cp "/Users/andrey.epifancev/projects/awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx" files/ && echo "$T"
cd /Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/tabular-parser/api && for f in "$T"/files/*; do ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "$f"; done
```
Expected (записать таблицей в отчёт задачи): ни одного `INVALID_FORMAT`; у плоских файлов (`26e4c90d`, `638ecb3d`, `f2e0e04e`, `7e49f8b5`, `45e06f7d`, `cb7e6eb0`, письмо) — `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, `account_code_raw` из шапки («76.Н.1», «76.АВ», «76.2», «60»), организация «ООО "Товарное хозяйство"» / «ООО Хреновская Нива», строки — COUNTERPARTY; `270b41da`/`738bfcf5` — `account_code_raw = "76"`, «Итого развернутое» — TOTAL, нарушение остаётся только у валютного «76.55» (трек A); `8c7e95d9` лист «60 сч.» — контрагенты COUNTERPARTY с предупреждением. Письмо — COUNTERPARTY по схеме «Организации», без предупреждения «вид субконто…». Отклонение — описать, не чинить молча. После — `rm -rf "$T"`.
- [ ] **Step 4: `make check-full`** в tabular-parser (нужен стек compose на портах `.env.test`; если порты заняты демо-стендом — стоп и вопрос контроллеру); `make check` в docs.
- [ ] **Step 5: `openspec validate osv-flat-form --strict`** (tabular-parser) — без ошибок.
- [ ] **Step 6: Commit** — docs: `§1 правил ОСВ: плоская форма 1С — счёт из шапки, «Итого развернутое», header_account, контрагенты по плану счетов (tabular-parser#59, AURORA-1661)`; tabular-parser: `docs: README, CLAUDE.md и change osv-flat-form (#59)`.

View File

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

View File

@ -0,0 +1,125 @@
## Context
Плоская форма ОСВ 1С по одному счёту (образцы — выгрузка контура `awp-ca/result_dir.zip` и файл письма
`awp-ca/ОСВ сч. 60 за 2 кв. 2026.xlsx`; в git не кладутся, только локальная сверка):
```
r2 Оборотно-сальдовая ведомость по счету: 76.Н.1
r3 Контрагенты ← вид субконто (у письма — «Организации», у O-3 — «Материалы»)
r4 за 2025 г. ← у письма: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива»
r5 ООО "Товарное хозяйство" ← организация (у письма — хвост «План счетов …» строки периода)
r7 Единица измерения: рубль (код по ОКЕИ 383)
r9 Субконто | Сальдо на начало периода | Обороты за период | Сальдо на конец периода
r10 | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r11 Август - Агро ООО | … суммы … ← сразу контрагенты: outline 0, отступ 0, строки счёта нет
…
Итого развернутое | … ← у файлов ТХО и письма — ПЕРЕД «Итого»
Итого | …
```
Вариант со строками субсчетов (ТХО 76, `270b41da`): строки «76.2», «76.5», …, «76.Н.1» на уровне 0 без строки «76»,
«Итого развернутое» перед «Итого». Вариант «Субконто» с отступами (ОСВОКТ-24, `8c7e95d9`, лист «60 сч.»): «60.01», под
ним контрагенты с отступом 1, «Итого», затем «Итого развернутое» — контрагенты сейчас `OTHER` без имени.
Текущее поведение (main `aef2d35`): письмо и `26e4c90d` — `INVALID_FORMAT` на шаге «иерархия строк»; `270b41da` —
`account_code_raw = "76.2"` из первой строки, «Итого развернутое» (r27) — `OTHER` ребёнок «76.Н.1», нарушения
`parent_sum [13, 26]`, `balance_identity [27]` (13 — валюта, трек A).
## Goals / Non-Goals
**Goals:** разбор плоской формы без `INVALID_FORMAT`; счёт и организация из шапки; «Итого развернутое» — итог в любом
порядке; контрагенты расчётных счетов видны ядру как `COUNTERPARTY`.
**Non-Goals:** см. proposal (валюта и `data_type` — трек A; «Учредители»; прочие неизвестные виды субконто; «Организации»
узнаются только как синоним COUNTERPARTY по множественному числу, R-10, R-17).
## Decisions
Решения владельца — текст ожидаемого результата AURORA-1661 (заведён по команде владельца 16.10.2026) и запуск трека
19.10.2026 («трек B можешь запускать»):
1. Счёт — из шапки «по счету[:] X»; строки таблицы — его аналитика. Фолбэк OQ-134 (код первой строки-счёта) — только
если в шапке счёта нет.
2. «Итого развернутое» — итоговая строка в любом порядке относительно «Итого».
3. Если вид субконто не назван: на расчётных счетах (58, 60, 62, 66, 67, 75, 76, 80; 71 и 73 исключены — R-9) нижний уровень —
контрагенты (`COUNTERPARTY`) с предупреждением.
Реализация:
- **Код счёта с двоеточием** — `header._account_code`: хвост после метки `account` очищается от ведущих «:» и пробелов
перед `_ACCOUNT_CODE`. Отдельный синоним не нужен: «по счету:» уже матчит `\bпо\s+счету\b`.
- **Строка вида субконто** — ячейки шапки строго между строкой названия (ячейка с меткой `account`) и строкой периода
(`_find_period`), если название выше периода. Они не кандидаты в организацию. Их текст (через запятую) — виды
субконто: элемент, который `rules.groups` узнаёт **явной** записью `mapping` (не `default`), добавляется в схему
после ACCOUNT, если схема варианта 1 состоит из одной ACCOUNT («Субконто»). Неузнанный элемент («Материалы»,
единственное «Организация» — R-17) в схему не добавляется; «Организации» узнаётся как COUNTERPARTY (R-10, R-17).
- **Организация** — порядок: хвост метки «План счетов» (`header_markers.chart_of_accounts`, новый ключ) → первая
не-метка шапки без строки вида субконто → хвост названия ведомости (как сейчас).
- **Итоговый блок** — `collect_body_rows` возвращает номером «Итого» **первую** строку итогового блока: вторые итоговые
строки, стоящие непосредственно перед основной «Итого» (`total_row_secondary`), входят в блок. Всё, что использует
`row >= total_row` (каскад, классификация, `inn_rows`), работает без изменений. Сверка «Итого = корень»
(`checks._total`) берёт первую строку TOTAL, текст которой не матчит `total_row_secondary`; если такой нет — первую
TOTAL (как сейчас).
- **Стратегия `header_account`** — `hierarchy.strategy_header_account`: все строки уровня 0. `resolve_levels` получает
`header_account: bool` (в шапке есть код счёта) и пробует стратегию после `account_pattern` и перед `sums`, только
если `header_account` и ни одна строка тела не `is_account`. Код предупреждения `OSV_HIERARCHY_BY_HEADER_ACCOUNT`,
текст «иерархия строк — плоский список под счётом шапки (header_account)».
- **Счёт шапки у корня ветки** — `classify_rows` получает `header_account: str | None`; корневая ветка
`_Branch(header_account, 0)` вместо `_Branch(None, 0)`.
- **«Итого» без корня ACCOUNT** — `checks.total_matches_root`: нет корневой ACCOUNT, но есть строки уровня 0 (кроме
показателей) — сверка «Итого» с их суммой (допуски R-21, как у суммы уровня 0); строк уровня 0 нет — прежнее правило
R-13 (пустая/нулевая «Итого»).
- **Контрагенты по плану счетов** — `osv_groups.yaml: counterparty_accounts` (коды верхнего уровня; запись `76`
покрывает `76`, `76.2`, `76.Н.1`, но не `760` — как `accounts_without_analytic_balance`). В `_classify_row`: позиция
`1` (первая не-счётная) за пределами схемы и счёт ветки из списка → `COUNTERPARTY` без имени группировки; ИНН — по
общим правилам (колонка, строка ИНН, хвост названия). Глубже — `OTHER`, как сейчас. Строка, которую словарь групп
явно знает как не-COUNTERPARTY (валюта), остаётся `OTHER` (R-18). Предупреждение по одному на счёт (R-13; `OSV_ROWS`,
`osv.py`): «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов —
проверьте».
## Decisions made without discussion
| № | Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|---|
| 1 | R-1 | 19.10.2026 | Строка между названием и периодом — вид субконто, не организация | 6 из 98 xlsx контура (все — «Контрагенты»), в `fixtures` таких строк нет — регрессии golden нет | Организация, напечатанная между названием и периодом, не найдётся: предупреждение «наименование организации не найдено», ядро сверяет по ИНН |
| 2 | R-2 | 19.10.2026 | Известный вид субконто из этой строки — группировка схемы; неизвестный — не добавляется | «Контрагенты» даёт COUNTERPARTY с именем без угадывания по счёту; «Материалы» — не контрагенты (вид «Организации» затем узнан R-10, R-17) | Неизвестный вид на нерасчётном счёте остаётся OTHER без имени (как сейчас) |
| 3 | R-3 | 19.10.2026 | `header_account` — перед `sums`, только без строк-счетов и при счёте в шапке | Плоский список с равными суммами соседей `sums` вложил бы ложно; условие — определение плоской формы 1С | Файл без оформления и счетов, но с реальной вложенностью по суммам станет плоским → «Итого» не сойдётся → `needs_check` (видно) |
| 4 | R-4 | 19.10.2026 | Корень ветки наследует счёт шапки | Ядро группирует свод по `account_code`; без этого у строк плоского файла счёт `null` | Строки до первой строки-счёта в обычных файлах получат счёт шапки вместо `null` (golden покажет) |
| 5 | R-5 | 19.10.2026 | «Итого» без корня ACCOUNT сверяется с суммой уровня 0 | Плоский список — несколько корней без счёта; прежнее R-13 требовало нулевой «Итого» | — (пустая ОСВ — прежнее правило) |
| 6 | R-6 | 19.10.2026 | Правило расчётных счетов — только позиция 1, только без имени в схеме | 1С кладёт контрагентов первым видом на этих счетах; именованные группировки («Работники организаций», «Учредители») не трогаем | Физлица на 76 без названия вида → COUNTERPARTY: попадут в «Осталось N» сопоставления (OQ-158); 71/73 исключены R-9 |
| 7 | R-7 | 19.10.2026 | Синтетика `s40`–`s44` | Номера `s33`–`s39` оставлены параллельному треку A, чтобы реестр генератора не конфликтовал | Перенумерация при слиянии |
| 8 | R-8 | 19.10.2026 | «Учредители» на счёте 80 не трогаем: именованная группировка остаётся OTHER (R-6 сохраняется) | Принято по рекомендации сессии при одобрении запуска владельцем; именованные группировки правило расчётных счетов не переопределяет | Учредители не станут COUNTERPARTY автоматически — гудвилл (§4 п. 2) потребует ручного сопоставления |
| 9 | R-9 | 19.10.2026 | Счета 71 и 73 исключены из `counterparty_accounts`; итог: 58, 60, 62, 66, 67, 75, 76, 80 | Подотчётные и персонал — физлица, а не контрагенты (OQ-158); принято по рекомендации сессии | Контрагенты на 71/73 без названия вида останутся OTHER и не попадут в сопоставление |
| 10 | R-10 (сужен R-17) | 19.10.2026 | «Организации» — синоним COUNTERPARTY в `osv_groups.yaml` (`^организаци`; после R-17 — `^организации\b`) | Вид субконто письма (сч. 60) — организации-контрагенты; принято по рекомендации сессии | Другой смысл вида «Организации» на иных счетах даст COUNTERPARTY; видно в golden/предупреждениях |
| 11 | R-11 (отменён R-16) | 19.10.2026 | Данные синтетики s40, s41, s42 исправлены: у «Итого развернутое» добавлены обороты, равные суммам строк (s40: 650/750, s41: 5000/4991, s42: 5700/6600) | В плане у этой строки обороты пусты, поэтому тождество строки (начало + обороты = конец) не сходилось, и файлы не могли стать `done` независимо от кода; бриф плана ошибочен | s40–s42 расходятся с исходным текстом плана. **Приёмка Task 6 показала:** в реальных файлах контура у «Итого развернутое» обороты тоже пусты (`45e06f7d`, `638ecb3d`, `7e49f8b5`, `f2e0e04e`, `270b41da`, `738bfcf5`, письмо), тождество на этой строке остаётся нарушенным, и файл уходит в `needs_check`: правка данных синтетики скрыла дефект кода, а не данных; решается отдельно (см. отчёт task-6). **Отменён рулингом R-16:** данные s40–s42 возвращены к исходному тексту плана |
| 12 | R-12 | 19.10.2026 | Строка вида субконто не даёт группировку из ячеек-меток шапки («Субконто», «Счет»), меток валюты («Валюта БУ, НУ») и ячеек, на которые срабатывает паттерн `currency` | Ячейка между названием и периодом может быть меткой шапки, а не видом субконто; иначе в схему попали бы ложные группировки (T2.2, ревью задачи 2) | Метка, совпавшая с настоящим видом субконто, не станет группировкой; вид «Организации» уже учтён (R-10) |
| 13 | R-13 | 19.10.2026 | Предупреждение `OSV_ROWS` «вид субконто в шапке не назван…» — одно на каждый различный `account_code_raw` строк, принятых по плану счетов | План требовал «один раз на документ»; у листа с несколькими субсчетами (62.01, 62.02) заметка с точным кодом полезнее одной общей | Больше заметок на листах с несколькими субсчетами (лист `67 сч.` — четыре); на статус не влияют (`info`) |
| 14 | R-14 | 19.10.2026 | `Expect.data_type` в `test_synthetic_files.py` стал `str \| None`: у s40–s44 ожидается `None` | В шапке нет БУ/НУ; умолчание BU — зона трека A (`data_type`) | При слиянии трека A ожидания s40–s44 придётся поправить на `BU` |
| 15 | R-15 | 19.10.2026 | Предупреждение `OSV_TOTAL` при несходимости «Итого» без корня ACCOUNT, но со строками уровня 0, говорит «≠ сумме N строк уровня 0» (T4.1) | Прежний текст «корневого счёта нет» вводил в заблуждение для плоского списка | Текст в `metrics.warnings` изменился; снимков с прежним текстом нет |
| 16 | R-16 (уточнён R-19) | 19.10.2026 | Вторые итоговые строки (`total_row_secondary`, «Итого развернутое») исключены из проверки тождества строки; сверка по основной «Итого» сохранена; R-11 отменён | «Итого развернутое» перед «Итого» содержит только развёрнутые сальдо, без оборотов (подтверждено 7 реальными файлами контура: `638ecb3d`, `45e06f7d`, `f2e0e04e`, `7e49f8b5`, `270b41da`, `738bfcf5`, письмо); решение владельца: «Итого развернутое» — итоговая строка в любом порядке. Исключение оказалось шире обоснования: после «Итого» строка идёт с оборотами и сходящимся тождеством — см. R-19 | Тождество на вторых итогах без оборотов не проверяется: ошибка в сальдо этой строки не будет поймана; основная «Итого» по-прежнему проверяется тождеством и сверкой с корнем/суммой уровня 0 |
| 17 | R-17 (заменяет R-10) | 19.10.2026 | Синоним вида субконто сужен до множественного числа: `^организации\b` → COUNTERPARTY; единственное «Организация» остаётся OTHER и в схему не добавляется | «Организация» в шапке вариантов 1/2 — собственная организация учёта (измерение отчитывающейся организации); на контуре `dae56120` (сч. 83) и `02c5df51` (сч. 80) схема `[Счет, Организация]` из-за широкого паттерна стала `[ACCOUNT, COUNTERPARTY]` (финальное ревью I-1) | Прочие формы написания («Организации-контрагенты» и т. п.) не узнаются; вид во множественном числе с иным смыслом на нерасчётном счёте даст COUNTERPARTY (OQ-171) |
| 18 | R-18 | 19.10.2026 | Правило расчётных счетов (`counterparty_accounts`) не применяется к строке, которую словарь групп явно знает как не-COUNTERPARTY; паттерн валюты расширен до `^(в\s+)?валют` | Строки «Валюта», «Валюта CNY/EUR/USD/руб», «В валюте» под 76.55 становились контрагентами (`270b41da`, `738bfcf5`; финальное ревью I-2) | Контрагент с названием на «Валют…» («Валютные решения ООО») остаётся OTHER без имени и не попадёт в сопоставление |
| 19 | R-19 | 19.10.2026 | Тождество у второй итоговой строки пропускается, только если обе колонки оборотов пусты и на листе есть другая TOTAL; иначе строка проверяется как обычная | На контуре «Итого развернутое» перед «Итого» — без оборотов, после «Итого» — с оборотами и сходящимся тождеством; R-16 пропускал обе (финальное ревью I-4) | Вторая итоговая без оборотов и с ошибочным сальдо не ловится; единственная итоговая без оборотов проверяется, и пустое тождество может дать нарушение |
| 20 | R-21 | 19.10.2026 | Шаг вверх от «Итого» проходит строки-показатели, но включает их в итоговый блок, только если выше найдена вторая итоговая строка; показатели второй итоговой строки без оборотов наследуют пропуск тождества (R-19); счётчик TOTAL в тождестве не считает показатели | 1С штатно даёт «Показатели БУ, НУ» + «Итого развернутое»: без шага «Итого развернутое» оставалась в теле и становилась фиктивным контрагентом, а НУ давала нарушение тождества (ревью интеграции I-6, I-7, M-13) | Ошибка сальдо показателя второй итоговой строки не ловится |
## Risks / Trade-offs
- [OQ-158] Контрагенты по плану счетов на 76 и прочих расчётных счетах могут включать физлиц: «Осталось N» в
сопоставлении вырастет. Смягчение: предупреждение `OSV_ROWS` на документе; список уже сужен (без 71/73, R-9).
- [Трек A] Слит в main (#58): строки без «Выводимые данные» — `data_type = BU` с предупреждением, `header.data_type`
остаётся `null`; golden s40–s44 обновлены при rebase (O-3 ledger).
- [Golden] Правило расчётных счетов срабатывает только при схеме из одной ACCOUNT без названия вида; у реальных файлов
и пакета ДКБ вид назван в шапке, поэтому снимки `osv/**` и `osv-dkb/**` не изменились (OTHER → COUNTERPARTY не
случилось). Эффект проверен на файлах контура.
## Open Questions
Решения R-8…R-10 приняты по рекомендации сессии при одобрении запуска 19.10.2026; вопросы **открыты** (docs: `07-open-questions.md`,
OQ-169…OQ-172), в коде действует допущение:
- OQ-169 — «Учредители» на 80 (O-29, `02c5df51`) остаются OTHER (R-8); гудвилл §4 п. 2 — вопрос владельцу.
- OQ-170 — счета 71 и 73 (подотчётные, персонал) исключены из списка расчётных (R-9).
- OQ-171 — «Организации» (множественное число) — синоним COUNTERPARTY (R-10, R-17); «Организация» — OTHER.
- OQ-172 — безымянная аналитика первого уровня на счёте 63 не входит в список расчётных счетов: нужен ли 63. На файлах
контура таких строк нет; 51 строка — в fixtures, golden `osv-dkb/nm/2025-Q3/НМ_ОСВ_63_Контрагенты_…json`, и имя файла
(«63_Контрагенты») — довод за включение счёта 63.

View File

@ -0,0 +1,56 @@
## Why
AURORA-1661 (баг заказчика, найден прогоном выгрузки контура AURORA-1639, 16.10.2026; свод `admin/1639-contour-run`,
находки O-2, O-3, O-4, O-29): ОСВ 1С по одному счёту в **плоской форме** — шапка «Оборотно-сальдовая ведомость по счету:
76» (с двоеточием), колонка «Субконто», строки счёта и группировок нет, сразу список контрагентов, затем «Итого
развернутое» и «Итого» — не разбирается или разбирается неверно:
- без строки счёта и без оформления — `INVALID_FORMAT` «иерархия строк не определена» (12 файлов контура, в том числе
файл «ОСВ сч. 60 за 2 кв. 2026» из письма заказчика; 6 из 10 файлов Компании 4, tp#49);
- «по счету: X» с двоеточием не распознаётся: счёт берётся из первой строки таблицы («76.2» вместо «76»), вместо
организации — вид субконто («Контрагенты», «Материалы»);
- «Итого развернутое» стоит **перед** «Итого» и становится строкой данных под последним счётом (ложные нарушения
«сумма детей = родитель» и тождества строки);
- если вид субконто не назван (в шапке только «Субконто»), контрагенты 60/62/67/76 молча получают `OTHER`: ядро их не
сопоставляет, в сводной ОСВ нет признака ГСК/ГК/Внешний, ВГО их не видит (O-29, ОСВОКТ-24).
Валютная часть AURORA-1661 («Валюта USD» / «В валюте», O-7) — в параллельном треке вместе с AURORA-1613 (путь
`data_type`), не здесь.
Срез: доработка Среза 2a (парсер ОСВ, `09-delivery-plan.md`). Репозитории: tabular-parser (код, эта спека),
arm-ka-docs (`05-domain-rules.md` §1.1, §1.3, §1.4, `07-open-questions.md`), arm-ka-fixtures (синтетика и golden).
Таблицы DBML v5.19 не затрагиваются; контракт результата не меняется (`CONTRACT_VERSION` прежний).
## What Changes
- **Шапка (§1.1).** Код счёта после «по счету» — и с двоеточием («по счету: 76.Н.1»); счёт шапки важнее первой
строки таблицы (фолбэк OQ-134 — только без счёта в шапке, как сейчас). Ячейки между строкой названия ведомости и
строкой периода — строка вида субконто, не организация; организация — также из метки «План счетов <организация>».
Известные виды субконто из этой строки («Контрагенты») становятся группировками схемы после ACCOUNT.
- **«Итого развернутое» (§1.4).** Вторая итоговая строка — `TOTAL` и **перед**, и после «Итого»; сверка «Итого =
корень» берёт основную «Итого», а не «Итого развернутое».
- **Плоский список (§1.3).** Новая стратегия иерархии `header_account` — все строки уровня 0 под счётом шапки:
применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта; стоит в каскаде перед
`sums`. Строки без счёта в ветке наследуют счёт шапки (`account_code_raw`). «Итого» без корневой строки ACCOUNT
сверяется с суммой строк уровня 0.
- **Контрагенты по плану счетов (§1.1/§1.3).** На расчётных счетах (58, 60, 62, 66, 67, 75, 76, 80 — данные
`rules/osv_groups.yaml`) первая не-счётная позиция без названия в схеме — `COUNTERPARTY` с предупреждением `OSV_ROWS`.
- Синтетика `s40`–`s44`, golden; правила §1 и открытые вопросы в docs.
## Capabilities
### Modified Capabilities
- `osv-parsing`: «Шапка документа и схема группировок», «Иерархия строк — каскад стратегий», «Классификация строк»,
«Контрольные суммы определяют статус».
## Non-goals
- Валюта («Валюта <код>», «В валюте»), колонка «Показатели», `data_type` строки при молчащей шапке — трек A (AURORA-1613
+ валютная часть 1661), слит в main (#58). После его слияния строки плоских файлов без «Выводимые данные» получают
`data_type = BU` с предупреждением `OSV_HEADER`, `header.data_type` остаётся `null`; в свод они попадают.
- «Учредители» на 80 (именованная группировка `OTHER`, O-29) — не меняется (R-8).
- Вид субконто «Организации» (множественное число) — синоним COUNTERPARTY в `osv_groups.yaml` (R-10, R-17), единственное
«Организация» — собственная организация учёта, не вид субконто; прочие неизвестные виды в схему не
добавляются, позиция остаётся без названия и подпадает под правило расчётных счетов.
- Склейка кварталов (O-28), «Итого» с потерянной подписью, буквенные счета первого сегмента (OQ-103) — без изменений.

View File

@ -0,0 +1,266 @@
## MODIFIED Requirements
### Requirement: Шапка документа и схема группировок
Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (хвост метки «План счетов <организация>»; иначе первая непустая текстовая строка, не являющаяся меткой шапки и не лежащая между строкой названия ведомости и строкой периода; иначе хвост названия ведомости; trim),
`account_code_raw` (после «по счету»/«по счёту», в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца),
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
`has_inn = true`. Ячейки между строкой названия ведомости (с меткой «по счету») и строкой периода, если название выше периода, — **строка вида субконто** (плоская форма 1С: «Контрагенты», «Организации», «Материалы»): она MUST NOT становиться `organization_name`; если схема варианта 1 состоит из одной ACCOUNT («Субконто»), элементы этой строки (через запятую), которые словарь `rules/osv_groups.yaml` узнаёт явной записью `mapping` (не `default`), SHALL добавляться в схему после ACCOUNT; неузнанные элементы в схему MUST NOT попадать. Элемент вида `<группировка>.ИНН` (суффикс после последней точки — по словарю `service_columns.inn`,
префикс — имя группировки `COUNTERPARTY` этой же шапки; вариант 2 — элемент списка «субконто: …», вариант 1 — строка блока
заголовка) MUST NOT попадать в `group_schema`: группировка-владелец получает `has_inn = true`, а элемент служит
**подсказкой** для строк ИНН (требование «Строки ИНН рядом с контрагентом»): стоит после владельца — ИНН под
наименованием, перед владельцем — над. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по
`rules/periods.yaml` (§1.5, `ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме
обязательных по контракту, — `null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и
предупреждение (контракт требует строку).
#### Scenario: Вариант 1 с тремя строками группировок
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`,
`account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`,
`data_type = "BU"`, `currency = "RUB"`
#### Scenario: ОСВ по субсчёту и годовой период
- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls`
- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true`
#### Scenario: Вариант 2
- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто:
Контрагенты, Договоры», заголовком «Субконто»
- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"`
#### Scenario: Реквизит ИНН после группировки
- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` (шапка «субконто: Контрагенты,
Контрагенты.ИНН»)
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН под наименованием»
#### Scenario: Реквизит ИНН перед группировкой
- **WHEN** в синтетической ОСВ шапка «субконто: Контрагенты.ИНН, Контрагенты»
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН над наименованием»
#### Scenario: Плоская форма — счёт с двоеточием, вид субконто, организация
- **WHEN** разбирается синтетическая ОСВ `s40` (шапка «Оборотно-сальдовая ведомость по счету: 76.Н.1», строка
«Контрагенты», «за 2025 г.», строка «ООО "Товарное хозяйство"», колонка «Субконто», сразу контрагенты)
- **THEN** `account_code_raw = "76.Н.1"` без предупреждения «код счёта … взят из первой строки-счёта»,
`organization_name = "ООО \"Товарное хозяйство\""`, `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Контрагенты)]`
#### Scenario: Организация из «План счетов»
- **WHEN** разбирается синтетическая ОСВ `s41` (шапка «…по счету: 60», строка «Организации», «за 2 Квартал 2026 г.
План счетов ООО Хреновская Нива»)
- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`,
`group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17)
#### Scenario: Счёт шапки важнее строки таблицы
- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2»)
- **THEN** `account_code_raw = "76"`
### Requirement: Иерархия строк — каскад стратегий
Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
или ведущие пробелы; шаблон кода счёта и жирность; плоский список под счётом шапки (`header_account`); равенство «сумма детей = родитель». Стратегия `header_account` (все строки — уровень 0) применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта (плоская форма 1С). Стратегия подходит, если даёт дерево,
где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться:
она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна
строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `warnings[]` документа листа предупреждением
`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`severity = info`); в `metrics.warnings` строк `sheet=<имя> hierarchy=…` нет. `parent_row_number` SHALL указывать на ближайшую
предыдущую строку с меньшим уровнем.
#### Scenario: Outline level (реальные файлы)
- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных)
- **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
`parent_row_number` — строка `66.03`
#### Scenario: Без outline level
- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`)
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
кодам счетов и жирности (`s03`)
#### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
#### Scenario: Пустая ОСВ — тривиальное дерево
- **WHEN** в ОСВ нет строк данных кроме «Итого»
- **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя
#### Scenario: Плоский список под счётом шапки
- **WHEN** разбирается синтетическая ОСВ `s40` либо `s41` (без outline, отступов и строк-счетов; сразу контрагенты)
- **THEN** вместо `INVALID_FORMAT` — `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, все строки данных уровня 0 без родителя,
`account_code_raw` каждой строки равен коду счёта шапки
### Requirement: Классификация строк
Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с
контрагентом»). Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр
учитывается, IGNORECASE нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или** 1–3
заглавные буквы кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название:
`^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой
пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1);
`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER;
строка «Итого» и вторые итоговые строки («Итого развернутое») в итоговом блоке сразу перед ней или сразу за ней, включая строки-показатели итоговых строк, — TOTAL; строки-показатели входят в блок перед «Итого», только если выше них стоит вторая итоговая строка.
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`)
→ ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным
ведущим нулём) дополняется нулём слева (R-24);
2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»);
3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»
(`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»).
Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки-показатели (требование «Строки-показатели
и колонка «Показатели»») SHALL получать `group_type = OTHER`, `data_type` по метке, MUST NOT занимать позицию в
`group_schema` и MUST NOT наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки
родителя, R-19); их суммы остаются в шести полях. Строка с меткой «БУ» в колонке «Показатели» получает `data_type = BU`.
Остальные строки получают `data_type` шапки, а если шапка вид данных не называет — `BU` и одно предупреждение `OSV_HEADER`
на лист «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type` остаётся `null`). Все строки, включая TOTAL, SHALL присутствовать в `rows`
(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать.
#### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
#### Scenario: ИНН в конце названия
- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями)
- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст
#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН
- **WHEN** разбирается синтетическая ОСВ `s19`
- **THEN** `counterparty_inn` — 10 цифр до «/»
#### Scenario: Название субсчёта в строке счёта
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
#### Scenario: Буквенный субсчёт
- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
#### Scenario: Строки Вал./Кол.
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
`account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
#### Scenario: Шапка без вида данных
- **WHEN** разбирается синтетическая ОСВ `s36` (шапка без «Выводимые данные», колонки «Показатели» нет)
- **THEN** `header.data_type = null`, у всех строк данных и «Итого» `data_type = BU`, в `warnings` одно `OSV_HEADER` «вид
данных в шапке не указан — строки без метки приняты как БУ», статус `done`
#### Scenario: «Валюта <код>» — обычная строка
- **WHEN** в синтетической ОСВ `s35` под контрагентом строка «Валюта USD» с рублёвыми суммами
- **THEN** `group_type = OTHER`, `data_type` шапки, строка участвует в сверке «сумма детей = родитель»
#### Scenario: Контрагенты расчётного счёта без названия вида
- **WHEN** разбирается синтетическая ОСВ `s43` (лист как ОСВОКТ-24 «60 сч.»: колонка «Субконто», строка «60.01», под ней
контрагенты с отступом) либо `s41`
- **THEN** контрагенты — COUNTERPARTY без `group_name`, `counterparty_name` — текст строки; в `warnings[]` — `OSV_ROWS`
«вид субконто в шапке не назван…»; на нерасчётном счёте (синтетика `s44`, счёт 10) те же строки — OTHER без
предупреждения
### Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть
структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`,
`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять
структуру под сходимость.
Вторая итоговая строка (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки,
только если у неё обе колонки оборотов за период пусты и на листе есть другая строка TOTAL: перед «Итого» она несёт
одни развёрнутые сальдо (R-16, уточнён R-19). Вторая итоговая строка с оборотами (в 1С она бывает и после «Итого», с
оборотами) и единственная итоговая строка листа проверяются тождеством как обычные; основная «Итого» проверяется как
прежде.
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 90, 91 и 99 строки аналитики несут
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
(`accounts_without_analytic_balance`, состав `90`, `91`, `99`; счёт 90 добавлен решением владельца 19.10.2026). Строка относится к такому счёту, если
код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
(перестановка сторон при совпавшем нетто) не проверяется.
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
(закреплено тестом как осознанное поведение R-21).
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
`group_type` строк ветки; суммы и проверки это не затрагивает.
#### Scenario: Все реальные файлы сходятся
- **WHEN** разбирается каждый файл `fixtures/osv/**`
- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
#### Scenario: Испорченная сумма
- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
`row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк
#### Scenario: Сальдо родителя свёрнуто нетто
- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
или расхождение оборотов — нарушение
#### Scenario: Счёт 91 — аналитика только с оборотами
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
#### Scenario: «Итого» не найдена
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена»
#### Scenario: Пустая ОСВ
- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
`hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
`OSV_NO_DATA_ROWS` «нет строк данных»
#### Scenario: Предупреждение ОСВ
- **WHEN** на листе ОСВ есть строка без родителя
- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6;
в `metrics.warnings` конверта строки `sheet=…` нет
#### Scenario: «Итого развернутое» перед «Итого»
- **WHEN** разбирается синтетическая ОСВ `s42` (субсчета 76.2, 76.5, 76.Н.1 на уровне 0, затем «Итого развернутое» и
«Итого», «Итого» — свёрнутое нетто)
- **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»;
`total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done`
#### Scenario: «Итого развернутое» без оборотов
- **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется
- **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной
«Итого» её `row_number` в списке остаётся
#### Scenario: «Итого развернутое» с оборотами
- **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами
- **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет
#### Scenario: Показатели второй итоговой строки без оборотов
- **WHEN** колонка «Показатели», «Итого развернутое» без оборотов (до или после «Итого») и под ней строка «НУ» без оборотов,
на листе есть основная «Итого»
- **THEN** строка «НУ» блока пропускает тождество строки так же, как «Итого развернутое»; `balance_identity_violations`
пуст, `status = done`; строка «НУ» под последней строкой данных в итоговый блок не входит
#### Scenario: «Итого» плоского списка
- **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто)
- **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`,
`OSV_TOTAL`, `needs_check`

View File

@ -0,0 +1,25 @@
Исполнение — по плану `docs/superpowers/plans/2026-10-19-osv-flat-form.md` (ветка `feat/59-osv-flat-form`, issue #59).
## 1. Синтетика и снимки (tabular-parser, fixtures)
- [x] 1.1 Синтетика `s40`–`s44` в `api/tests/synthetic/generate.py`, файлы в `fixtures/osv/synthetic/`, снимки текущего поведения
## 2. Шапка (tabular-parser)
- [x] 2.1 Код счёта «по счету: X», строка вида субконто, «План счетов <организация>» (`header.py`, `rules.py`, `osv_headers.yaml`)
## 3. Итоговый блок (tabular-parser)
- [x] 3.1 «Итого развернутое» перед «Итого» — TOTAL; сверка по основной «Итого» (`classify.py`, `checks.py`, `osv.py`)
## 4. Плоский список (tabular-parser)
- [x] 4.1 Стратегия `header_account`, счёт шапки у корня ветки, «Итого» без корня ACCOUNT (`hierarchy.py`, `classify.py`, `checks.py`, `osv.py`, `osv_warnings.py`)
## 5. Контрагенты по плану счетов (tabular-parser)
- [x] 5.1 `counterparty_accounts` в `osv_groups.yaml`, COUNTERPARTY на позиции 1 за схемой, предупреждение `OSV_ROWS`
## 6. Документация и приёмка (docs, tabular-parser)
- [x] 6.1 `05-domain-rules.md` §1.1, §1.3, §1.4; `07-open-questions.md` (OQ-169…OQ-172); README и CLAUDE.md парсера; прогон реальных файлов контура (отчёт task-6; отклонения — «Итого развернутое» без оборотов); `make check`, `make check-full`