Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63
@ -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` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
14
README.md
14
README.md
@ -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` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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", "Субконто")]
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
314
api/tests/unit/parser/test_osv_flat_form.py
Normal file
314
api/tests/unit/parser/test_osv_flat_form.py
Normal 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 == []
|
||||||
@ -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)
|
||||||
|
|||||||
217
docs/superpowers/plans/2026-10-19-osv-flat-form.md
Normal file
217
docs/superpowers/plans/2026-10-19-osv-flat-form.md
Normal 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)`.
|
||||||
2
openspec/changes/osv-flat-form/.openspec.yaml
Normal file
2
openspec/changes/osv-flat-form/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-19
|
||||||
125
openspec/changes/osv-flat-form/design.md
Normal file
125
openspec/changes/osv-flat-form/design.md
Normal 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.
|
||||||
56
openspec/changes/osv-flat-form/proposal.md
Normal file
56
openspec/changes/osv-flat-form/proposal.md
Normal 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) — без изменений.
|
||||||
266
openspec/changes/osv-flat-form/specs/osv-parsing/spec.md
Normal file
266
openspec/changes/osv-flat-form/specs/osv-parsing/spec.md
Normal 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`
|
||||||
25
openspec/changes/osv-flat-form/tasks.md
Normal file
25
openspec/changes/osv-flat-form/tasks.md
Normal 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`
|
||||||
Loading…
x
Reference in New Issue
Block a user