Строки-показатели ОСВ: колонка «Показатели», «В валюте», BU по умолчанию (#58) #60

Merged
aep merged 14 commits from feat/58-osv-indicators into main 2026-10-19 12:05:00 +03:00
28 changed files with 1763 additions and 84 deletions

View File

@ -62,7 +62,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
(`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 → sums` (+ `trivial`); `inn_rows.py` —
строки ИНН рядом с контрагентом (OQ-163); `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/`, 127 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 32 синтетических) — регрессия, а не - Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 131 файл: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 36 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого». проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
@ -85,9 +85,9 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения. контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (91, 99) — данные `api/rules/osv_checks.yaml`, не код; **Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код;
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
«Итого» сохраняются. Счёт 90 в список не входит. Не расширять список «чтобы файл прошёл» — только по решению владельца. «Итого» сохраняются. Счёт 90 добавлен решением владельца 19.10.2026 (O-1 из #58). Не расширять список «чтобы файл прошёл» — только по решению владельца.
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с; **Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют

View File

@ -120,8 +120,9 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `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`) |
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) | | `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) | | `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (90, 91, 99) — вне тождества строки (`osv_checks.yaml`) |
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики | | `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) | | `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) | | `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
@ -153,15 +154,29 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
`warnings[]` его документа (`OSV_*`, `info`): стратегия иерархии (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`), свёрнутое сальдо, `warnings[]` его документа (`OSV_*`, `info`): стратегия иерархии (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`), свёрнутое сальдо,
нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
### Строки-показатели и колонка «Показатели» (§1.3, AURORA-1613, AURORA-1661)
Колонка «Показатели» (`service_columns.indicator`; заголовок часто «Показа-тели») несёт вид суммы строки: «БУ» — обычная
строка данных (`data_type = BU`), «НУ», «Вал.», «Кол.» — показатели (`NU`/`CURRENCY`/`QUANTITY` по словарю
`data_types.yaml`: `indicator_column`), «ПР», «ВР» (`indicator_column_untyped`, `data_type = null`) и неизвестная метка — показатель с
`data_type = null` (неизвестная — ещё и предупреждение `OSV_ROWS`). Показатель — строка с признаком `indicator`: он
не входит в суммирование «сумма детей = родитель» и не проверяется как родитель, его родитель в дереве — предыдущая строка
данных (`indicators.py`, вне каскада иерархии); `row_value` строки с меткой колонки — сама метка, колонка остаётся в
`extra`. Строка «В валюте» (пары «Валюта <код>» / «В валюте» на одном уровне) — показатель `CURRENCY`, «Валюта <код>» —
обычная строка в рублях. Итоговые строки «Итого» по БУ и НУ входят в таблицу; сверка идёт с первой итоговой
строкой, не показателем. Если шапка не называет вид данных, строки без собственной метки получают `BU`, а в
`warnings[]` листа — `OSV_HEADER` «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type`
остаётся `null`).
### Контрольные суммы (§1.4) ### Контрольные суммы (§1.4)
Допуск ±0.01, пустое значение = 0. Строки «Вал.», «Кол.» и код валюты (`data_type = CURRENCY | QUANTITY`) в суммирование Допуск ±0.01, пустое значение = 0. Строки-показатели (признак строки: НУ/ПР/ВР/Кол./Вал./«В валюте»/код валюты, метка колонки «Показатели» или
не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по текст) в суммирование не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по
колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение 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`, в сверку не входит. Строка сумм
@ -191,10 +206,10 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает). и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
**Счета без сальдо по аналитике (OQ-132).** У 91 и 99 строки аналитики несут только обороты, сальдо закрывается на **Счета без сальдо по аналитике (OQ-132).** У 90, 91 и 99 строки аналитики несут только обороты, сальдо закрывается на
субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы `needs_check`. субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы `needs_check`.
Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (начальный состав `91`, `99`; Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (состав `90`, `91`, `99`;
90 не входит — внутри года у его субсчетов сальдо есть). Строка относится к счёту списка по коду ближайшего счёта её ветки: счёт 90 добавлен решением владельца 19.10.2026, O-1 из #58). Строка относится к счёту списка по коду ближайшего счёта её ветки:
код совпадает с записью или является её субсчётом (`91`, `91.01`, но не `910`). Сверка «сумма детей = родитель» остаётся: код совпадает с записью или является её субсчётом (`91`, `91.01`, но не `910`). Сверка «сумма детей = родитель» остаётся:
обороты — всегда, сальдо — только у родителя с дочерними субсчетами (корень → 91.01/91.02/91.09); у родителя с одной обороты — всегда, сальдо — только у родителя с дочерними субсчетами (корень → 91.01/91.02/91.09); у родителя с одной
аналитикой сальдо не сверяется. «Итого» сверяется как обычно. аналитикой сальдо не сверяется. «Итого» сверяется как обычно.
@ -378,7 +393,7 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки —
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check` которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
завершается кодом 1. завершается кодом 1.
Каталог `fixtures/osv/synthetic/` — 19 синтетических файлов `s01`–`s19` (генератор — `tests/synthetic/generate.py`): Каталог `fixtures/osv/synthetic/` — синтетические файлы `s01`–`s36` (генератор — `tests/synthetic/generate.py`):
форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.). форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.).
Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml` Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а

View File

@ -12,7 +12,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|---|---|---| |---|---|---|
| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | | `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (начальный состав `91`, `99`; OQ-132): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения | | `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (состав `90`, `91`, `99`; OQ-132, 90 добавлен 19.10.2026): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |

View File

@ -18,6 +18,22 @@ standalone_header:
row: row:
- ['^вал\.$', CURRENCY] - ['^вал\.$', CURRENCY]
- ['^кол(ичество|\s?во)?\.?$', QUANTITY] - ['^кол(ичество|\s?во)?\.?$', QUANTITY]
# строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях
- ['^в\s+валюте$', CURRENCY]
# Метка колонки «Показатели» (§1.3, AURORA-1613): БУ — обычная строка с data_type BU; остальные — строки-показатели
# (вид сумм предыдущей строки данных, вне проверок «дети = родитель» и «Итого»)
indicator_column:
- ['^бу$', BU]
- ['^ну$', NU]
- ['^кол\.?$', QUANTITY]
- ['^вал\.?$', CURRENCY]
# Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null.
# Прикладной сценарий НУ и разниц — аналитика AURORA-1669
indicator_column_untyped:
- '^пр$'
- '^вр$'
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases` # Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED] currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]

View File

@ -6,8 +6,10 @@
# - сверка «сумма детей = родитель» сохраняется: обороты сверяются всегда, сальдо — только у родителя, у которого # - сверка «сумма детей = родитель» сохраняется: обороты сверяются всегда, сальдо — только у родителя, у которого
# есть субсчета (корень со своими субсчетами); у родителя с одной лишь аналитикой сальдо не сверяется; # есть субсчета (корень со своими субсчетами); у родителя с одной лишь аналитикой сальдо не сверяется;
# - «Итого» сверяется как обычно. # - «Итого» сверяется как обычно.
# Запись — код счёта верхнего уровня («91»); к нему относятся и его субсчета (91.01, 91.09), но не «910». Счёт 90 не # Запись — код счёта верхнего уровня («91»); к нему относятся и его субсчета (91.01, 91.09), но не «910».
# входит: внутри года у его субсчетов сальдо обычно есть. # Решение владельца 19.10.2026: счёт 90 добавлен в список (O-1 из #58): у его аналитики сальдо нет, при этом у родителей
# 90.xx.y сальдо на конец есть.
accounts_without_analytic_balance: accounts_without_analytic_balance:
- '90'
- '91' - '91'
- '99' - '99'

View File

@ -31,12 +31,16 @@ debit:
credit: credit:
- '^(кредит|кт|к т)\b' - '^(кредит|кт|к т)\b'
# Служебные колонки таблицы по тексту заголовка. Код знает только колонку ИНН; прочие колонки таблицы (КПП, код, # Служебные колонки таблицы по тексту заголовка. Код знает колонки ИНН и «Показатели»; прочие колонки таблицы (КПП, код,
# количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют. # количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют.
# (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.) # (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.)
service_columns: service_columns:
inn: inn:
- '\bинн\b' - '\bинн\b'
# Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР
# (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column`
indicator:
- '^показа\s?тели$'
# Итоговая строка таблицы (начало текста первой колонки) # Итоговая строка таблицы (начало текста первой колонки)
total_row: total_row:

View File

@ -4,19 +4,20 @@
1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit = 1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit =
closing_debit − closing_credit`; closing_debit − closing_credit`;
2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.») 2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки-показатели (`indicator`: «Вал.», «Кол.»,
в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) — НУ и прочие метки колонки «Показатели») в суммирование не входят и сами родителями не проверяются.
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо); сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы.
Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только Счета без сальдо по аналитике (90, 91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут
обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2 только обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2
обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется. аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
@ -45,8 +46,6 @@ TOLERANCE = Decimal("0.01")
# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks` # Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
WARNING_ROWS_SHOWN = 20 WARNING_ROWS_SHOWN = 20
_ZERO = Decimal(0) _ZERO = Decimal(0)
# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят
_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"})
_ROOT_LEVEL = 0 _ROOT_LEVEL = 0
# Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом # Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом
_OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6) _OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6)
@ -68,7 +67,7 @@ def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
def _is_indicator(item: ClassifiedRow) -> bool: def _is_indicator(item: ClassifiedRow) -> bool:
return item.data_type in _INDICATOR_DATA_TYPES return item.indicator
def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool: def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool:
@ -114,7 +113,7 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
или по нетто при свёрнутом сальдо родителя (см. описание модуля). или по нетто при свёрнутом сальдо родителя (см. описание модуля).
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются. Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не
проверяется. проверяется.
Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по
@ -147,8 +146,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
"""Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит).""" """Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят)."""
return next((item for item in rows if item.group_type == "TOTAL"), None) return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None)
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:

View File

@ -31,8 +31,9 @@ from src.parser.deadline import Deadline
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter from src.parser.grid import Sheet, column_letter
from src.parser.hierarchy import BodyRow, Levels from src.parser.hierarchy import BodyRow, Levels
from src.parser.indicators import is_indicator_total, read_label
from src.parser.numbers import column_inn, split_inn_suffix, to_money from src.parser.numbers import column_inn, split_inn_suffix, to_money
from src.parser.rules import GroupType, Rules, first_match, normalize_text from src.parser.rules import GroupType, Rules
__all__ = [ __all__ = [
"ACCOUNT_RE", "ACCOUNT_RE",
@ -42,7 +43,6 @@ __all__ = [
"RowGroupType", "RowGroupType",
"classify_rows", "classify_rows",
"collect_body_rows", "collect_body_rows",
"row_data_type",
] ]
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или # Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
@ -82,6 +82,7 @@ class ClassifiedRow:
counterparty_inn: str | None counterparty_inn: str | None
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` — вид по метке
# --- Строки таблицы --- # --- Строки таблицы ---
@ -184,10 +185,12 @@ def _is_secondary_total(text: str, rules: Rules) -> bool:
def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет).""" """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет):
«Итого развернутое» и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613)."""
last = total last = total
for row in rows[rows.index(total) + 1 :]: for row in rows[rows.index(total) + 1 :]:
if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): secondary = _is_secondary_total(sheet.text_at(row, header.text_col), rules)
if not (secondary or is_indicator_total(sheet, row, header, rules)):
break break
last = row last = row
return last return last
@ -233,7 +236,9 @@ def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple
found.update( found.update(
col col
for col, _ in sheet.row_cells(row) for col, _ in sheet.row_cells(row)
if header.text_col < col < first_sum and col != header.inn_col and col <= header.right_edge if header.text_col < col < first_sum
and col not in (header.inn_col, header.indicator_col)
and col <= header.right_edge
) )
return (header.text_col, *sorted(found)) return (header.text_col, *sorted(found))
@ -247,8 +252,15 @@ def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, s
return label_cols[0], "" return label_cols[0], ""
def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str]) -> BodyRow: def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строке, приходит явно
sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str], rules: Rules
) -> BodyRow:
label_col, text = _label_of(sheet, row, label_cols) label_col, text = _label_of(sheet, row, label_cols)
label = read_label(sheet, row, text, header, rules)
if label.text is not None:
text = label.text # у строки с меткой колонки «Показатели» текст — метка, не название
if label.warning is not None:
warnings.append(label.warning)
account = ACCOUNT_RE.match(text) account = ACCOUNT_RE.match(text)
bold, indent = _text_cell_style(sheet, row, label_col) bold, indent = _text_cell_style(sheet, row, label_col)
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
@ -264,6 +276,8 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[
outline=sheet.outline(row), outline=sheet.outline(row),
sums=_sums(sheet, row, header.columns, warnings), sums=_sums(sheet, row, header.columns, warnings),
label_col=label_col, label_col=label_col,
indicator=label.indicator,
label_data_type=label.data_type,
) )
@ -304,7 +318,7 @@ def collect_body_rows(
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
if index % DEADLINE_CHECK_EVERY_ROWS == 0: if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("суммы строк") deadline.check("суммы строк")
body.append(_body_row(sheet, row, header, label_cols, warnings)) body.append(_body_row(sheet, row, header, label_cols, warnings, rules))
return body, total_row, warnings return body, total_row, warnings
@ -333,14 +347,6 @@ def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None
return values if any(value is not None for value in values.values()) else None return values if any(value is not None for value in values.values()) else None
def row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
found = first_match(rules.data_types.row, text)
if found is not None:
return found
return "CURRENCY" if normalize_text(text).upper() in currency_codes else None
@dataclass(frozen=True) @dataclass(frozen=True)
class _Branch: class _Branch:
"""Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта.""" """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""
@ -362,6 +368,7 @@ class _Kind:
data_type: DataType | None data_type: DataType | None
counterparty_name: str | None = None counterparty_name: str | None = None
counterparty_inn: str | None = None counterparty_inn: str | None = None
indicator: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@ -385,31 +392,31 @@ def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType,
def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]: def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]:
"""Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей.""" """Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей."""
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть code = inherited.account_code_raw
doc_data_type = context.doc_data_type if item.indicator:
# Строка-показатель (§1.3): не уровень группировки, а вид сумм родителя; позицию в схеме не занимает
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки
own = item.label_data_type or context.doc_data_type
account = ACCOUNT_RE.match(item.text) if item.is_account else None account = ACCOUNT_RE.match(item.text) if item.is_account else None
if account is not None: if account is not None:
code = account.group(1) code = account.group(1)
_, name = _schema_group(context.schema, _ACCOUNT_POSITION) _, name = _schema_group(context.schema, _ACCOUNT_POSITION)
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0) return _Kind("ACCOUNT", name, code, own), _Branch(code, 0)
code = inherited.account_code_raw
data_type = row_data_type(item.text, context.rules, context.currency_codes)
if data_type is not None:
return _Kind("OTHER", None, code, data_type), inherited
if item.row in context.unpaired: if item.row in context.unpaired:
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
return _Kind("OTHER", None, code, doc_data_type), inherited return _Kind("OTHER", None, code, own), inherited
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)
if group_type != "COUNTERPARTY": if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, doc_data_type), branch return _Kind(group_type, name, code, own), branch
inn_col = context.header.inn_col inn_col = context.header.inn_col
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка ИНН рядом (`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, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
@ -430,8 +437,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
- «Итого» (`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` — код как в файле; дети наследуют код ветки;
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень - строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) —
группировки, а вид сумм родителя; позицию в схеме она не занимает; OTHER, `indicator=True`, `data_type` метки (НУ/CURRENCY/QUANTITY/`None`), `account_code_raw` ветки родителя,
без контрагента: это не уровень группировки, а вид сумм родителя; позицию в схеме не занимает; итоговая
строка-показатель — TOTAL с тем же признаком;
- вид данных строки — метка (`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`.
@ -446,7 +456,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
if total_row is not None and item.row >= total_row: if total_row is not None and item.row >= total_row:
# Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал) # Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал)
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро # ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH kind = _Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator)
branch = _ROOT_BRANCH
level, parent = 0, None level, parent = 0, None
else: else:
parent = levels.parent[item.row] parent = levels.parent[item.row]
@ -466,6 +477,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
counterparty_inn=kind.counterparty_inn, counterparty_inn=kind.counterparty_inn,
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,
) )
) )
return result return result

View File

@ -122,6 +122,7 @@ class TableHeader:
right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица
extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет) extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет)
group_label_rows: list[int] # строки блока, где в text_col начинается текст группировки («Счет», «Контрагенты») group_label_rows: list[int] # строки блока, где в text_col начинается текст группировки («Счет», «Контрагенты»)
indicator_col: int | None = None # колонка «Показатели» — метка вида суммы строки, §1.3
# --- Текст ячеек --- # --- Текст ячеек ---
@ -381,13 +382,20 @@ def inn_from_cell(cell: Cell) -> str | None:
return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None: def _service_col(sheet: Sheet, block: range, text_col: int, first_sum: int, patterns: Iterable[Pattern]) -> int | None:
"""Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр.""" """Первая колонка между текстом и суммами, чей нормализованный заголовок в блоке матчит `patterns`."""
inn_patterns = rules.headers.service_columns["inn"]
for row in block: for row in block:
for col, text in _texts(sheet, row): for col, text in _texts(sheet, row):
if text_col < col < first_sum and _search(inn_patterns, text): if text_col < col < first_sum and _search(patterns, text):
return col return col
return None
def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None:
"""Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр."""
by_title = _service_col(sheet, block, text_col, first_sum, rules.headers.service_columns["inn"])
if by_title is not None:
return by_title
values: dict[int, list[Cell]] = {} values: dict[int, list[Cell]] = {}
for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS): for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS):
for col, cell in sheet.row_cells(row): for col, cell in sheet.row_cells(row):
@ -465,6 +473,8 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No
last_row = _block_last_row(sheet, group_row, sum_cells, rules) last_row = _block_last_row(sheet, group_row, sum_cells, rules)
text_col = _text_col(sheet, group_row, last_row, first_sum) text_col = _text_col(sheet, group_row, last_row, first_sum)
inn_col = _inn_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules) inn_col = _inn_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules)
indicator_patterns = rules.headers.service_columns.get("indicator", [])
indicator_col = _service_col(sheet, range(group_row, last_row + 1), text_col, first_sum, indicator_patterns)
partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, []) partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, [])
occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set()) occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
@ -485,6 +495,7 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No
right_edge=right_edge, right_edge=right_edge,
extra_cols=dict(sorted(extra_cols.items())), extra_cols=dict(sorted(extra_cols.items())),
group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules), group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules),
indicator_col=indicator_col,
) )

View File

@ -14,6 +14,7 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Protocol from typing import Protocol
from src.contract.common import DataType
from src.parser.deadline import Deadline from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError from src.parser.errors import InvalidFormatError
@ -59,6 +60,8 @@ class BodyRow:
outline: int outline: int
sums: tuple[Decimal | None, ...] sums: tuple[Decimal | None, ...]
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -0,0 +1,83 @@
"""Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных."""
from collections.abc import Sequence
from dataclasses import dataclass
from src.contract.common import DataType
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader
from src.parser.grid import Sheet
from src.parser.hierarchy import ROOT_LEVEL, BodyRow, Levels, resolve_levels
from src.parser.rules import Rules, first_match, normalize_text
__all__ = ["RowLabel", "column_label", "is_indicator_total", "levels_with_indicators", "read_label", "row_data_type"]
@dataclass(frozen=True)
class RowLabel:
"""Вид строки по метке: показатель ли она, `data_type` метки, текст строки взамен исходного, предупреждение."""
indicator: bool
data_type: DataType | None
text: str | None = None # для метки колонки «Показатели» — сама метка (`row_value`)
warning: str | None = None
def row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
found = first_match(rules.data_types.row, text)
if found is not None:
return found
return "CURRENCY" if normalize_text(text).upper() in currency_codes else None
def column_label(sheet: Sheet, row: int, header: TableHeader) -> str:
"""Метка строки в колонке «Показатели» (пусто — колонки нет или ячейка пуста)."""
return "" if header.indicator_col is None else sheet.text_at(row, header.indicator_col)
def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Rules) -> RowLabel:
"""Метка колонки «Показатели» важнее текста строки: «БУ» — строка данных BU, прочие — показатели; без метки —
показатель по тексту («Вал.», «Кол.», «Кол-во», «В валюте», код валюты), иначе обычная строка без своего вида."""
label = column_label(sheet, row, header)
if label:
data_type = first_match(rules.data_types.indicator_column, label)
if data_type == "BU":
return RowLabel(indicator=False, data_type="BU")
if data_type is not None or any(p.search(label) for p in rules.data_types.indicator_column_untyped):
return RowLabel(indicator=True, data_type=data_type, text=label)
warning = (
f"строка {row}: метка «{label}» в колонке «Показатели» не распознана — строка не участвует в проверках"
)
return RowLabel(indicator=True, data_type=None, text=label, warning=warning)
data_type = row_data_type(text, rules, frozenset(rules.data_types.currency_codes))
return RowLabel(indicator=data_type is not None, data_type=data_type)
def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool:
"""Итоговая строка-показатель за «Итого»: метка колонки «Показатели» не «БУ»."""
label = column_label(sheet, row, header)
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:
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
"""
levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline)
by_row: dict[int, int] = {}
parent: dict[int, int | None] = {}
previous: int | None = None
for item in rows:
if not item.indicator:
by_row[item.row], parent[item.row] = levels.by_row[item.row], levels.parent[item.row]
if total_row is None or item.row < total_row:
previous = item.row
continue
if previous is None or (total_row is not None and item.row >= total_row):
by_row[item.row], parent[item.row] = ROOT_LEVEL, None
else:
by_row[item.row], parent[item.row] = by_row[previous] + 1, previous
return Levels(by_row=by_row, parent=parent, strategy=levels.strategy)

View File

@ -18,7 +18,6 @@ from decimal import Decimal
from typing import Literal from typing import Literal
from src.contract.result import OsvGroup from src.contract.result import OsvGroup
from src.parser.classify import row_data_type
from src.parser.grid import Cell, Sheet from src.parser.grid import Cell, Sheet
from src.parser.header import InnHint from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels from src.parser.hierarchy import BodyRow, Levels
@ -104,7 +103,6 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст
hint: InnHint | None, hint: InnHint | None,
rules: Rules, rules: Rules,
) -> _Tree: ) -> _Tree:
currency = frozenset(rules.data_types.currency_codes)
children: dict[int | None, list[int]] = {} children: dict[int | None, list[int]] = {}
for item in rows: for item in rows:
children.setdefault(levels.parent[item.row], []).append(item.row) children.setdefault(levels.parent[item.row], []).append(item.row)
@ -112,7 +110,7 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст
for siblings in children.values(): for siblings in children.values():
tree.position.update((row, index) for index, row in enumerate(siblings)) tree.position.update((row, index) for index, row in enumerate(siblings))
for item in rows: for item in rows:
tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree) tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, tree)
if hint is not None and hint.direction == "below": if hint is not None and hint.direction == "below":
# «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения
for item in rows: for item in rows:
@ -129,14 +127,13 @@ def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от
sheet: Sheet, sheet: Sheet,
hint: InnHint | None, hint: InnHint | None,
rules: Rules, rules: Rules,
currency: frozenset[str],
tree: _Tree, tree: _Tree,
) -> Kind: ) -> Kind:
if total_row is not None and item.row >= total_row: if total_row is not None and item.row >= total_row:
return "total" return "total"
if item.is_account: if item.is_account:
return "account" return "account"
if row_data_type(item.text, rules, currency) is not None: if item.indicator:
return "indicator" return "indicator"
found = row_inn(_label_cell(sheet, item), rules) found = row_inn(_label_cell(sheet, item), rules)
if found is None: if found is None:

View File

@ -11,6 +11,7 @@ from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from src.contract import OsvDocument, ResultWarning from src.contract import OsvDocument, ResultWarning
from src.contract.common import DataType
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
@ -19,7 +20,7 @@ from src.parser.detect import TableHeader, find_table_header
from src.parser.errors import InvalidFormatError from src.parser.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import resolve_levels from src.parser.indicators import levels_with_indicators
from src.parser.inn_rows import InnGlue, glue_inn_rows from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.osv_warnings import ( from src.parser.osv_warnings import (
OSV_HEADER, OSV_HEADER,
@ -37,6 +38,8 @@ __all__ = ["OsvParser", "SheetResult", "data_rows"]
LOCATOR_PREFIX = "sheet:" LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn" INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных" NO_DATA_ROWS_WARNING = "нет строк данных"
DEFAULT_DATA_TYPE: DataType = "BU"
DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"
def _columns(header: TableHeader) -> dict[str, str]: def _columns(header: TableHeader) -> dict[str, str]:
@ -181,7 +184,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 = resolve_levels(body, total_row, deadline) levels = levels_with_indicators(body, total_row, deadline)
step = "строки ИНН" step = "строки ИНН"
glue = glue_inn_rows( glue = glue_inn_rows(
body, body,
@ -194,13 +197,18 @@ class OsvParser:
rules=rules, rules=rules,
) )
step = "классификация строк" step = "классификация строк"
data_type = doc.data_type or DEFAULT_DATA_TYPE
# Вид данных шапки не указан: строки без метки приняты как БУ — предупреждение, если умолчание сработало
defaulted = doc.data_type is None and any(
not item.indicator and item.label_data_type is None for item in glue.rows
)
classified = classify_rows( classified = classify_rows(
glue.rows, glue.rows,
glue.levels, glue.levels,
schema.groups, schema.groups,
header, header,
sheet, sheet,
doc.data_type, data_type,
total_row, total_row,
rules, rules,
inns=glue.inns, inns=glue.inns,
@ -215,6 +223,7 @@ class OsvParser:
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 []),
*notes_of(OSV_ROWS, body_warnings), *notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_INN_ROWS, glue.warnings), *notes_of(OSV_INN_ROWS, glue.warnings),
] ]

View File

@ -317,6 +317,8 @@ class DataTypeRules(_Model):
header: list[tuple[Pattern, DataType]] header: list[tuple[Pattern, DataType]]
standalone_header: list[tuple[Pattern, DataType]] standalone_header: list[tuple[Pattern, DataType]]
row: list[tuple[Pattern, RowDataType]] row: list[tuple[Pattern, RowDataType]]
indicator_column: list[tuple[Pattern, DataType]]
indicator_column_untyped: list[Pattern]
currency_codes: list[str] currency_codes: list[str]
currency_aliases: dict[str, list[Pattern]] currency_aliases: dict[str, list[Pattern]]

View File

@ -425,6 +425,67 @@ def form_sheet(name: str, *lines: str) -> SheetSpec:
return sheet return sheet
MERGED = "<объединено>" # ячейка текста — продолжение объединения строки выше
INDICATOR_TITLE = "Показа-\nтели"
@dataclass(frozen=True)
class GridRow:
"""Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели»,
шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level."""
text: str | None
sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None]
label: str | None = None
outline: int = 0
def _grid_osv( # noqa: PLR0913 - лист описывается полностью одним вызовом
*,
account: str,
group_label: str,
rows: Sequence[GridRow],
data_type_line: str | None,
indicator_column: bool,
name: str = "Лист_1",
) -> SheetSpec:
"""ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, соседи."""
sheet = SheetSpec(name)
sheet.put(1, 1, ORG)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}")
sheet.put(3, 1, "Период: 3 квартал 2025 г.")
if data_type_line is not None:
sheet.put(4, 1, data_type_line)
sheet.put(5, 1, "Параметры:")
sheet.put(5, 3, "Валюта БУ, НУ: Руб")
label_col, first_sum = 4, 5
top = 7
sheet.put(top, 1, "Счет")
if indicator_column:
sheet.put(top, label_col, INDICATOR_TITLE)
sheet.merge(top, label_col, top + 1, label_col)
for index, key in enumerate(("opening", "turnover", "closing")):
col = first_sum + 2 * index
sheet.put(top, col, GROUP_TITLES[key])
sheet.merge(top, col, top, col + 1)
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
sheet.put(top + 1, 1, group_label)
for row, item in enumerate(rows, start=top + 2):
if item.text == MERGED:
sheet.merge(row - 1, 1, row, 1)
elif item.text:
sheet.put(row, 1, item.text)
if item.label is not None:
sheet.put(row, label_col, item.label)
if item.outline:
sheet.outline[row] = item.outline
for offset, value in enumerate(item.sums):
if value is not None:
sheet.put(row, first_sum + offset, D(value))
return sheet
# --- Файлы --- # --- Файлы ---
@ -612,6 +673,68 @@ def _s16() -> list[SheetSpec]:
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")] return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
def _s33() -> list[SheetSpec]:
"""Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки
БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними."""
rows = [
GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1),
GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1),
GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1),
GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1),
GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
]
return [
_grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True)
]
def _s34() -> list[SheetSpec]:
"""Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1),
GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1),
GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1),
GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1),
GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1),
GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)]
def _s35() -> list[SheetSpec]:
"""«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне."""
rows = [
GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1),
GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2),
GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3),
GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3),
GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3),
GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3),
GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)]
def _s36() -> list[SheetSpec]:
"""Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
]
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)]
# --- Запись файлов --- # --- Запись файлов ---
@ -842,6 +965,10 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
"s31_rows_below_total.xlsx": (_s31, to_xlsx), "s31_rows_below_total.xlsx": (_s31, to_xlsx),
"s32_total_with_suffix.xlsx": (_s32, to_xlsx), "s32_total_with_suffix.xlsx": (_s32, to_xlsx),
"s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx),
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx),
} }
FILE_NAMES = tuple(FILES) FILE_NAMES = tuple(FILES)
@ -860,7 +987,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…s32 (xlsx, один xlsb и один xls) в указанный каталог.", description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.",
) )
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv) args = parser.parse_args(argv)

View File

@ -69,6 +69,10 @@ EXPECTED_NAMES = [
VAL_KOL_VO_FILE, VAL_KOL_VO_FILE,
ROWS_BELOW_TOTAL_FILE, ROWS_BELOW_TOTAL_FILE,
TOTAL_WITH_SUFFIX_FILE, TOTAL_WITH_SUFFIX_FILE,
"s33_indicator_column_bu_nu.xlsx",
"s34_indicator_column_qty_val_diff.xlsx",
"s35_currency_slices.xlsx",
"s36_no_data_type_header.xlsx",
] ]
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)"
@ -93,7 +97,7 @@ class Expect:
status: ResultStatus = ResultStatus.DONE status: ResultStatus = ResultStatus.DONE
documents: int = 1 documents: int = 1
variant: int = 1 variant: int = 1
data_type: str = "BU" data_type: str | None = "BU"
organization: str = ORG organization: str = ORG
# Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
variants: tuple[int, ...] = () variants: tuple[int, ...] = ()
@ -144,6 +148,10 @@ EXPECTED: dict[str, Expect] = {
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
# итог подписан «Итого по счету 60»: точного «Итого» нет # итог подписан «Итого по счету 60»: точного «Итого» нет
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True), TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True),
"s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),
"s34_indicator_column_qty_val_diff.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),
} }
@ -221,7 +229,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…s32 (s13 — xlsb; допустима деградация: нет только его).""" """В `fixtures/osv/synthetic/` лежат файлы набора s01…s36 (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]
@ -690,3 +698,59 @@ def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
assert doc.rows[-1].row_value == "Итого по счету 60" assert doc.rows[-1].row_value == "Итого по счету 60"
assert doc.rows[-1].group_type == "TOTAL" assert doc.rows[-1].group_type == "TOTAL"
def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None:
"""AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
outcome = parse_named(fixtures_dir, "s33_indicator_column_bu_nu.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
nu = [row for row in doc.rows if row.data_type == "NU"]
assert [row.row_value for row in nu] == ["НУ"] * 4
assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu)
for row in nu:
if row.group_type != "TOTAL":
parent = by_row[row.parent_row_number]
assert parent.data_type == "BU" and parent.row_number == row.row_number - 1
assert row.level == parent.level + 1
assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"]
assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"}
assert doc.header.data_type is None
texts = warnings_of(outcome)
assert not any("ниже таблицы" in text for text in texts), texts
assert not any("приняты как БУ" in text for text in texts), texts
assert outcome.status is ResultStatus.DONE
def test_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
"""«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
outcome = parse_named(fixtures_dir, "s34_indicator_column_qty_val_diff.xlsx")
doc = one_document(outcome)
types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}}
assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None}
unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text]
assert len(unknown) == 1, warnings_of(outcome)
assert outcome.status is ResultStatus.DONE
def test_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
"""AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
outcome = parse_named(fixtures_dir, "s35_currency_slices.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
in_currency = [row for row in doc.rows if row.row_value == "В валюте"]
assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"]
assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"]
slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")]
assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")}
assert outcome.status is ResultStatus.DONE
def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
"""Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
outcome = parse_named(fixtures_dir, "s36_no_data_type_header.xlsx")
doc = one_document(outcome)
assert doc.header.data_type is None
assert {row.data_type for row in doc.rows} == {"BU"}
assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
assert outcome.status is ResultStatus.DONE

View File

@ -26,6 +26,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
data_type: DataType | None = "BU", data_type: DataType | None = "BU",
text: str = "строка", text: str = "строка",
code: str | None = "60", code: str | None = "60",
indicator: bool = False,
) -> ClassifiedRow: ) -> ClassifiedRow:
body = BodyRow( body = BodyRow(
row=number, row=number,
@ -48,6 +49,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
counterparty_inn=None, counterparty_inn=None,
data_type=data_type, data_type=data_type,
extra=None, extra=None,
indicator=indicator,
) )
@ -198,8 +200,24 @@ def test_currency_and_quantity_children_are_excluded() -> None:
rows = [ rows = [
account(7, ROOT), account(7, ROOT),
row(8, ROOT, level=1, parent=7), row(8, ROOT, level=1, parent=7),
row(9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY"), row(
row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"), 9,
("1", None, "2", None, "3", None),
level=2,
parent=8,
group_type="OTHER",
data_type="CURRENCY",
indicator=True,
),
row(
10,
("7", None, None, None, "7", None),
level=2,
parent=8,
group_type="OTHER",
data_type="QUANTITY",
indicator=True,
),
total(11, ROOT), total(11, ROOT),
] ]
@ -299,7 +317,9 @@ def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> N
def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None: def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None:
rows = multi_root_tree() rows = multi_root_tree()
rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY")) rows.insert(
2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY", indicator=True)
)
assert total_matches_root(rows) is True assert total_matches_root(rows) is True
@ -376,7 +396,7 @@ def test_run_checks_warning_lists_are_capped() -> None:
assert "100," not in identity assert "100," not in identity
# --- Счета без сальдо по аналитике (OQ-132): 91, 99 --- # --- Счета без сальдо по аналитике (OQ-132): 90, 91, 99 ---
NO_SALDO = OsvCheckRules(accounts_without_analytic_balance=["91", "99"]) NO_SALDO = OsvCheckRules(accounts_without_analytic_balance=["91", "99"])
@ -464,3 +484,58 @@ def test_run_checks_without_violations_on_account_91_gives_clean_status() -> Non
assert checks.parent_sum_violations == [] assert checks.parent_sum_violations == []
assert checks.total_row_matches_root is True assert checks.total_row_matches_root is True
assert warnings == [] assert warnings == []
# --- Признак строки-показателя (#58) ---
def test_indicator_nu_child_is_excluded_from_parent_sum() -> None:
"""Строка-показатель НУ под родителем БУ в сумму детей не входит, хотя её data_type не CURRENCY/QUANTITY."""
rows = [
account(7, ("10", None, None, None, "10", None)),
row(8, ("10", None, None, None, "10", None), level=1, parent=7),
row(
9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True
),
]
assert parent_sums(rows) == []
def test_document_of_nu_rows_without_indicator_is_checked_as_usual() -> None:
"""«Выводимые данные: НУ» (s12): все строки НУ без признака — проверки идут как обычно."""
rows = [
row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"),
row(8, ("4", None, None, None, "4", None), level=1, parent=7, data_type="NU"),
row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"),
]
assert parent_sums(rows) == [7]
def test_indicator_with_same_data_type_as_parent_is_excluded_by_flag() -> None:
"""Признак — флаг, а не `data_type`: показатель НУ под родителем НУ не суммируется, не родитель и не «Итого»."""
rows = [
row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"),
row(8, ("10", None, None, None, "10", None), level=1, parent=7, data_type="NU"),
row(
9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True
),
row(10, ("3", None, None, None, "3", None), level=2, parent=9, data_type="NU"),
row(11, ("15", None, None, None, "15", None), group_type="TOTAL", data_type="NU", indicator=True),
row(12, ("10", None, None, None, "10", None), group_type="TOTAL", data_type="NU"),
]
assert parent_sums(rows) == []
assert total_matches_root(rows) is True
def test_total_skips_leading_indicator_total() -> None:
"""Первая итоговая строка без признака — основная «Итого», даже если перед ней стоит итог-показатель."""
rows = [
*tree(),
row(12, ("1", None, None, None, "1", None), group_type="TOTAL", data_type="NU", indicator=True),
]
rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True))
assert total_matches_root(rows) is True

View File

@ -12,13 +12,14 @@ import pytest
from gridbuilder import C, L, M, sheet_from_rows from gridbuilder import C, L, M, sheet_from_rows
from src.contract.common import DataType from src.contract.common import DataType
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows, row_data_type from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.deadline import Deadline from src.parser.deadline import Deadline
from src.parser.detect import find_table_header from src.parser.detect import find_table_header
from src.parser.errors import ParseTimeoutError from src.parser.errors import ParseTimeoutError
from src.parser.grid import Sheet from src.parser.grid import Sheet
from src.parser.header import build_group_levels from src.parser.header import build_group_levels
from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels
from src.parser.indicators import row_data_type
from src.parser.inn_rows import glue_inn_rows from src.parser.inn_rows import glue_inn_rows
from src.parser.rules import Rules, load_rules from src.parser.rules import Rules, load_rules
@ -1037,3 +1038,77 @@ def test_classified_rows_cover_every_body_row(rules: Rules) -> None:
parsed = parse(_basic(), rules) parsed = parse(_basic(), rules)
assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows] assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows]
# --- Колонка «Показатели» (#58) ---
INDICATOR_TOTAL_ROW = 7
def _indicator_sheet() -> Sheet:
return sheet_from_rows(
[
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), "60", "БУ", 100, None, 50, 30, 120, None], # 4
[L(1), M("ООО Поставщик", 1, 2), "БУ", 100, None, 50, 30, 120, None], # 5
[L(1), None, "НУ", 90, None, 40, 30, 100, None], # 6
[L(0), "Итого", "БУ", 100, None, 50, 30, 120, None], # 7
[L(0), None, "НУ", 90, None, 40, 30, 100, None], # 8
]
)
def test_indicator_column_rows_are_labelled_indicators(rules: Rules) -> None:
sheet = _indicator_sheet()
header = find_table_header(sheet, rules)
body, total_row, warnings = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
by_row = {item.row: item for item in body}
assert sorted(by_row) == [4, 5, 6, 7, 8]
assert total_row == INDICATOR_TOTAL_ROW
assert not any(TAIL_NUMBERS_MARK in warning for warning in warnings)
assert [by_row[row].indicator for row in (4, 5, 6, 7, 8)] == [False, False, True, False, True]
assert [by_row[row].text for row in (6, 8)] == ["НУ", "НУ"]
assert [by_row[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"]
assert by_row[5].text == "ООО Поставщик"
assert not by_row[6].is_account
def test_indicator_rows_and_totals_are_classified_by_flag(rules: Rules) -> None:
parsed = parse(_indicator_sheet(), rules, None)
by_row = {item.body.row: item for item in parsed.rows}
nu = by_row[6]
assert (nu.group_type, nu.data_type, nu.indicator) == ("OTHER", "NU", True)
assert (nu.account_code_raw, nu.counterparty_name, nu.counterparty_inn) == ("60", None, None)
nu_total = by_row[8]
assert (nu_total.group_type, nu_total.data_type, nu_total.indicator) == ("TOTAL", "NU", True)
assert by_row[7].indicator is False
assert by_row[7].data_type == "BU"
def test_label_data_type_wins_over_document_data_type(rules: Rules) -> None:
parsed = parse(_indicator_sheet(), rules, "NU")
assert {item.body.row: item.data_type for item in parsed.rows if not item.indicator} == {
4: "BU",
5: "BU",
7: "BU",
}
def test_row_without_label_takes_document_data_type(rules: Rules) -> None:
"""Строка без метки получает вид данных шапки; умолчание БУ подставляет `osv.py` до вызова классификатора."""
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100))
assert {item.body.text: item.data_type for item in parse(sheet, rules, "BU").rows} == {
"60": "BU",
"ООО Поставщик": "BU",
}
assert {item.body.text: item.data_type for item in parse(sheet, rules, "NU").rows} == {
"60": "NU",
"ООО Поставщик": "NU",
}

View File

@ -438,6 +438,36 @@ def test_header_without_text_column_left_of_sums_is_invalid_format(rules: Rules)
# --- ИНН и extra --- # --- ИНН и extra ---
def test_indicator_column_found_by_hyphenated_title(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Счет", "Показа-\nтели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: "Дебет", 3: 10, 8: 10})],
]
)
header = find_table_header(sheet, rules)
assert [header.indicator_col] == [2]
assert [header.text_col] == [1]
assert list(header.extra_cols) == [2]
def test_no_indicator_column_when_title_absent(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
assert find_table_header(sheet, rules).indicator_col is None
def test_kpp_between_inn_and_sums_goes_to_extra(rules: Rules) -> None: def test_kpp_between_inn_and_sums_goes_to_extra(rules: Rules) -> None:
sheet = sheet_from_rows( sheet = sheet_from_rows(
[ [

View File

@ -0,0 +1,138 @@
"""Метки строк-показателей (§1.3, #58): колонка «Показатели» и текст строки."""
from decimal import Decimal
import pytest
from gridbuilder import L, M, at, sheet_from_rows
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader, find_table_header
from src.parser.grid import Sheet
from src.parser.hierarchy import BodyRow
from src.parser.indicators import RowLabel, is_indicator_total, levels_with_indicators, read_label
from src.parser.rules import Rules, load_rules
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
LABEL_COL = 2
FIRST_ROW = 4
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _sheet(*labels: str, text: str = "60") -> tuple[Sheet, TableHeader]:
rows: list[list[object]] = [
[TITLE],
["Счет", None, M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
["Счет", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
rows.extend([L(0), *at({1: text, LABEL_COL: label, 3: 10, 8: 10})] for label in labels)
sheet = sheet_from_rows(rows)
return sheet, find_table_header(sheet, load_rules())
def _read(label: str, rules: Rules, text: str = "60") -> tuple[RowLabel, int]:
sheet, header = _sheet(label, text=text)
return read_label(sheet, FIRST_ROW, text, header, rules), FIRST_ROW
def test_bu_label_is_data_row(rules: Rules) -> None:
result, _ = _read("БУ", rules)
assert (result.indicator, result.data_type, result.text) == (False, "BU", None)
def test_nu_label_is_indicator_with_label_text(rules: Rules) -> None:
result, _ = _read("НУ", rules)
assert (result.indicator, result.data_type, result.text, result.warning) == (True, "NU", "НУ", None)
def test_untyped_label_has_no_data_type_and_no_warning(rules: Rules) -> None:
result, _ = _read("ПР", rules)
assert (result.indicator, result.data_type, result.text, result.warning) == (True, None, "ПР", None)
def test_unknown_label_warns_with_row_number(rules: Rules) -> None:
result, row = _read("Ед.", rules)
assert (result.indicator, result.data_type) == (True, None)
assert result.warning is not None
assert f"строка {row}" in result.warning
assert "Ед." in result.warning
assert "не участвует в проверках" in result.warning
def test_text_currency_label_without_column(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
["Счет", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "В валюте", 2: 10})],
[L(0), *at({1: "Валюта USD", 2: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.indicator_col is None
in_currency = read_label(sheet, 4, "В валюте", header, rules)
assert (in_currency.indicator, in_currency.data_type, in_currency.text) == (True, "CURRENCY", None)
plain = read_label(sheet, 5, "Валюта USD", header, rules)
assert (plain.indicator, plain.data_type) == (False, None)
def test_is_indicator_total(rules: Rules) -> None:
sheet, header = _sheet("НУ", "БУ", "")
assert is_indicator_total(sheet, FIRST_ROW, header, rules)
assert not is_indicator_total(sheet, FIRST_ROW + 1, header, rules)
assert not is_indicator_total(sheet, FIRST_ROW + 2, header, rules)
# --- Иерархия: показатели вне каскада ---
def _row(row: int, text: str, outline: int, *, indicator: bool = False) -> BodyRow:
return BodyRow(
row=row,
text=text,
is_account=text.isdigit(),
account_rank=1 if text.isdigit() else None,
bold=False,
indent=0,
outline=outline,
sums=(Decimal(1),) * 6,
indicator=indicator,
)
def test_indicators_are_children_of_previous_data_row_outside_cascade() -> None:
rows = [
_row(10, "60", 0),
_row(11, "НУ", 0, indicator=True),
_row(12, "Объект", 1),
_row(13, "НУ", 1, indicator=True),
_row(14, "Итого", 0),
_row(15, "НУ", 0, indicator=True),
]
levels = levels_with_indicators(rows, 14, Deadline(3600))
assert levels.strategy == "outline"
assert levels.by_row == {10: 0, 11: 1, 12: 1, 13: 2, 14: 0, 15: 0}
assert levels.parent == {10: None, 11: 10, 12: 10, 13: 12, 14: None, 15: None}
def test_indicator_first_in_table_is_root() -> None:
rows = [_row(10, "НУ", 0, indicator=True), _row(11, "60", 0)]
levels = levels_with_indicators(rows, None, Deadline(3600))
assert levels.by_row[10] == 0
assert levels.parent[10] is None
def test_levels_keep_sheet_row_order() -> None:
rows = [
_row(10, "60", 0),
_row(11, "НУ", 0, indicator=True),
_row(12, "Объект", 1),
_row(13, "НУ", 1, indicator=True),
]
levels = levels_with_indicators(rows, None, Deadline(3600))
assert list(levels.by_row) == [10, 11, 12, 13]
assert list(levels.parent) == [10, 11, 12, 13]

View File

@ -47,3 +47,41 @@ def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) -
assert not ok assert not ok
expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте" expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте"
assert expected in warnings, warnings assert expected in warnings, warnings
# --- Умолчание вида данных (#58, T6.1) ---
BU_DEFAULT_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"
def _labelled_sheet(bu_label: object) -> object:
return sheet_from_rows(
[
["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."],
[
"Счет",
None,
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
["Контрагенты", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), "60", bu_label, *A],
[L(1), "ООО Альфа", bu_label, *A],
[L(1), None, "НУ", *A],
[L(0), "Итого", bu_label, *A],
]
)
def test_default_warning_not_given_when_all_rows_are_labelled(rules: Rules) -> None:
result = OsvParser(rules).parse_sheet(_labelled_sheet("БУ"), Deadline(TIMEOUT_SECONDS)) # type: ignore[arg-type]
assert BU_DEFAULT_WARNING not in [note.message for note in result.notes]
assert result.document.header.data_type is None
def test_default_warning_given_once_when_rows_have_no_label(rules: Rules) -> None:
result = OsvParser(rules).parse_sheet(_labelled_sheet(None), Deadline(TIMEOUT_SECONDS)) # type: ignore[arg-type]
assert [note.message for note in result.notes].count(BU_DEFAULT_WARNING) == 1

View File

@ -384,8 +384,9 @@ def test_service_columns(rules: Rules, kind: str, text: str) -> None:
def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None: def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None:
"""R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком.""" """R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком.
assert list(rules.headers.service_columns) == ["inn"] Ключ `indicator` (колонка «Показатели», AURORA-1613) читает код — поэтому он в списке."""
assert list(rules.headers.service_columns) == ["inn", "indicator"]
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"]) @pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
@ -621,8 +622,8 @@ def test_currency_code_unknown(rules: Rules, text: str) -> None:
# --- Счета без сальдо по аналитике (osv_checks.yaml, OQ-132) --- # --- Счета без сальдо по аналитике (osv_checks.yaml, OQ-132) ---
def test_accounts_without_analytic_balance_are_91_and_99(rules: Rules) -> None: def test_accounts_without_analytic_balance_are_90_91_and_99(rules: Rules) -> None:
assert rules.checks.accounts_without_analytic_balance == ["91", "99"] assert rules.checks.accounts_without_analytic_balance == ["90", "91", "99"]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -633,8 +634,12 @@ def test_accounts_without_analytic_balance_are_91_and_99(rules: Rules) -> None:
(" 91.09 ", True), (" 91.09 ", True),
("99", True), ("99", True),
("99.01.1", True), ("99.01.1", True),
("90", False), ("90", True),
("90.01", False), ("90.01", True),
("90.01.1", True),
(" 90.09 ", True),
("900", False),
("901", False),
("910", False), ("910", False),
("9", False), ("9", False),
("", False), ("", False),
@ -666,3 +671,30 @@ def test_account_code_in_osv_checks_must_be_digits_with_dots(tmp_path: Path, bad
with pytest.raises(RuntimeError, match=r"osv_checks\.yaml.*accounts_without_analytic_balance"): with pytest.raises(RuntimeError, match=r"osv_checks\.yaml.*accounts_without_analytic_balance"):
load_rules(target) load_rules(target)
# --- Колонка «Показатели» (§1.3, AURORA-1613) и «В валюте» (AURORA-1661) ---
@pytest.mark.parametrize(
("label", "expected"),
[("БУ", "BU"), ("НУ", "NU"), ("Кол.", "QUANTITY"), ("Кол", "QUANTITY"), ("Вал.", "CURRENCY")],
)
def test_indicator_column_labels(rules: Rules, label: str, expected: str) -> None:
assert first_match(rules.data_types.indicator_column, label) == expected
@pytest.mark.parametrize("label", ["ПР", "ВР"])
def test_indicator_column_untyped_labels(rules: Rules, label: str) -> None:
assert first_match(rules.data_types.indicator_column, label) is None
assert any(pattern.search(label) for pattern in rules.data_types.indicator_column_untyped)
def test_v_valyute_row_is_currency(rules: Rules) -> None:
assert first_match(rules.data_types.row, "В валюте") == "CURRENCY"
assert first_match(rules.data_types.row, "Валюта USD") is None
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
def test_indicator_service_column(rules: Rules, title: str) -> None:
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])

View File

@ -0,0 +1,551 @@
# Колонка «Показатели» и валютные строки ОСВ: план реализации
> **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:** Строки ОСВ, которые не являются данными БУ (метки колонки «Показатели» — НУ, Кол., Вал., ПР, ВР; строка «В валюте»), не ломают дерево и проверки строк БУ и не попадают в свод; строки без вида данных при молчащей шапке получают BU (AURORA-1613, AURORA-1661 — валютная часть).
**Architecture:** Признак строки-показателя `indicator` вычисляется при сборе строк (`collect_body_rows` → новый модуль `indicators.py`: метка колонки «Показатели» по словарю, иначе текст строки по `data_types.row`) и живёт в `BodyRow` и `ClassifiedRow`. Каскад иерархии получает строки без показателей; показатель привязывается к предыдущей строке данных (`indicators.levels_with_indicators`). Проверки читают признак вместо набора `data_type`. Вид данных строки: метка колонки → иначе шапка → иначе BU с предупреждением.
**Tech Stack:** Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec.
**Spec:** `tabular-parser/openspec/changes/osv-indicator-column/` (proposal.md, design.md, specs/osv-parsing/spec.md). Правило — `docs/context/05-domain-rules.md` §1.3, §1.4 (ветка docs `feat/58-osv-indicators`, уже закоммичено). Issue RSHB/arm-tabular-parser#58. Jira AURORA-1613, AURORA-1661 (валютная часть).
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-indicators/` — `tabular-parser/`, `docs/`, `fixtures/` на ветке `feat/58-osv-indicators`; `CLAUDE.md`, `.claude/`, `reference-old-arm/` — симлинки. Ниже `$WT` = этот путь. Git — только `git -C $WT/<repo>`. Команды Python — из `$WT/tabular-parser/api` с `ENV_FILE=.env.test .venv/bin/python -m pytest …`; `make` — из `$WT/tabular-parser` (сначала один раз `make venv`). codegraph: `codegraph_explore` с `projectPath` = `$WT/tabular-parser`.
**Параллельный трек B** (`/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/`, плоская форма AURORA-1661) правит `header.py` (`_account_code`, `_organization`), `hierarchy.py` (стратегии, `resolve_levels`), `classify.py` (`_find_total`, `_trailing_totals`), `checks.py` (`_total`), синтетику s37–s44. Этот план в `hierarchy.py` добавляет только поля `BodyRow`, в `_trailing_totals` и `_total` — по одному условию. Его файлы не трогать сверх этого; при конфликте слияния второй вливающийся трек добавляет своё условие к версии первого.
## Global Constraints
- Язык комментариев, докстрингов, предупреждений, коммитов — русский; идентификаторы — английские.
- Никаких координат в `src/parser/**` (кроме `grid.py`, `readers/`): AST-линтер `tests/unit/parser/test_no_coordinates.py`.
- Словари — данные (`api/rules/*.yaml`); каждая новая запись срабатывает хотя бы на одном файле (`tests/fixtures/test_rules_coverage.py`). Паттерны без дефисов, «ё» и переносов (текст нормализуется: дефис и перенос → пробел).
- Контракт результата и DBML не меняются. `data_type` строки — только значения контракта (`BU`, `NU`, `SUM`, `CURRENCY`, `QUANTITY`, `null`).
- Деньги — `Decimal`.
- Golden-снимки реальных файлов и s01–s32 MUST NOT измениться. Любой diff по ним — стоп, отчёт контроллеру, вопрос владельцу. `make golden-update` — только для новых s33–s36, отдельным коммитом в fixtures.
- Файлы контура (`/Users/andrey.epifancev/projects/awp-ca/result_dir.zip`) в git MUST NOT попадать; распаковка — только во временный каталог вне репозиториев (`mktemp -d`).
- Каждое решение без обсуждения — строкой в `openspec/changes/osv-indicator-column/design.md`, раздел `## Decisions made without discussion`. Маркеры находок T/C/I/M/R/O — по `~/.claude/CLAUDE.md`.
- Перед каждым коммитом в tabular-parser — `make check` из `$WT/tabular-parser` зелёный. Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Python-файлы правятся инструментами Edit/Write, не `sed`/heredoc через Bash.
## Review Focus
1. **Документ «Выводимые данные: НУ» (s12)** — все строки NU, проверки работают как раньше (признак показателя — не `data_type`).
2. **Строки «Вал.»/«Кол.»/код валюты в колонке текста (s04, s30, реальные файлы)** — родитель и уровень те же, golden без diff.
3. **Строка «НУ» под объединённой ячейкой названия** — `row_value = "НУ"`, а не повтор названия; не контрагент, не счёт.
4. **Вторая «Итого» с меткой «НУ»** — TOTAL, без предупреждения «ниже таблицы», сверка «Итого = корень» по строке БУ.
5. **Умолчание BU** — только строкам без своего вида; `header.data_type` остаётся `null`; предупреждение одно на лист и только если умолчание применилось.
---
### Task 1: Словари (модель: haiku)
**Files:**
- Modify: `api/rules/osv_headers.yaml` (`service_columns`)
- Modify: `api/rules/data_types.yaml`
- Modify: `api/src/parser/rules.py` (`DataTypeRules`)
- Test: `api/tests/unit/parser/test_rules.py`
- [ ] **Step 1: Тест загрузки и совпадений** — дописать в `test_rules.py` (по образцу соседних тестов словарей, фикстура `rules` модуля):
```python
@pytest.mark.parametrize(
("label", "expected"),
[("БУ", "BU"), ("НУ", "NU"), ("Кол.", "QUANTITY"), ("Кол", "QUANTITY"), ("Вал.", "CURRENCY")],
)
def test_indicator_column_labels(rules: Rules, label: str, expected: str) -> None:
assert first_match(rules.data_types.indicator_column, label) == expected
@pytest.mark.parametrize("label", ["ПР", "ВР"])
def test_indicator_column_untyped_labels(rules: Rules, label: str) -> None:
assert first_match(rules.data_types.indicator_column, label) is None
assert any(pattern.search(label) for pattern in rules.data_types.indicator_column_untyped)
def test_v_valyute_row_is_currency(rules: Rules) -> None:
assert first_match(rules.data_types.row, "В валюте") == "CURRENCY"
assert first_match(rules.data_types.row, "Валюта USD") is None
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
def test_indicator_service_column(rules: Rules, title: str) -> None:
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
```
- [ ] **Step 2: Запустить — падает** (`AttributeError: indicator_column`, `KeyError: 'indicator'`).
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_rules.py -q`
- [ ] **Step 3: Словари.** В `osv_headers.yaml`, `service_columns`, после `inn`:
```yaml
# Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР
# (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column`
indicator:
- '^показа\s?тели$'
```
В `data_types.yaml`: в `row` добавить `- ['^в\s+валюте$', CURRENCY]` с комментарием «строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях»; после `row` — новые разделы:
```yaml
# Метка колонки «Показатели» (§1.3, AURORA-1613): БУ — обычная строка с data_type BU; остальные — строки-показатели
# (вид сумм предыдущей строки данных, вне проверок «дети = родитель» и «Итого»)
indicator_column:
- ['^бу$', BU]
- ['^ну$', NU]
- ['^кол\.?$', QUANTITY]
- ['^вал\.?$', CURRENCY]
# Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null.
# Прикладной сценарий НУ и разниц — аналитика AURORA-1669
indicator_column_untyped:
- '^пр$'
- '^вр$'
```
- [ ] **Step 4: `rules.py`.** В `DataTypeRules` после `row`:
```python
indicator_column: list[tuple[Pattern, DataType]]
indicator_column_untyped: list[Pattern]
```
Проверить, что `OsvHeaderRules` не требует обязательных ключей кроме `inn` (новый ключ `indicator` читается кодом через `service_columns.get("indicator", [])` — Task 3).
- [ ] **Step 5: Тест зелёный**, затем `make check` (тест покрытия словарей упадёт на новых записях — это ожидаемо до Task 2; если `make check` красный только из-за `test_rules_coverage`, коммит допустим с пометкой в ledger, покрытие закрывает Task 7).
- [ ] **Step 6: Commit** `git -C $WT/tabular-parser commit -am "Словари: колонка «Показатели», «В валюте» (#58)"`.
---
### Task 2: Синтетика s33–s36 и ожидания (модель: sonnet)
**Files:**
- Modify: `api/tests/synthetic/generate.py`
- Modify: `api/tests/synthetic/test_synthetic_files.py`
- Create: `$WT/fixtures/osv/synthetic/s33_indicator_column_bu_nu.xlsx`, `s34_indicator_column_qty_val_diff.xlsx`, `s35_currency_slices.xlsx`, `s36_no_data_type_header.xlsx`
- [ ] **Step 1: Построитель листа «по сетке».** В `generate.py` после `form_sheet` добавить (константы `ORG`, `GROUP_TITLES`, `DEBIT`, `CREDIT`, `D` — уже есть):
```python
MERGED = "<объединено>" # ячейка текста — продолжение объединения строки выше
INDICATOR_TITLE = "Показа-\nтели"
@dataclass(frozen=True)
class GridRow:
"""Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели»,
шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level."""
text: str | None
sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None]
label: str | None = None
outline: int = 0
def _grid_osv( # noqa: PLR0913 - лист описывается полностью одним вызовом
*,
account: str,
group_label: str,
rows: Sequence[GridRow],
data_type_line: str | None,
indicator_column: bool,
name: str = "Лист_1",
) -> SheetSpec:
"""ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, строки-соседи."""
sheet = SheetSpec(name)
sheet.put(1, 1, ORG)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}")
sheet.put(3, 1, "Период: 3 квартал 2025 г.")
if data_type_line is not None:
sheet.put(4, 1, data_type_line)
sheet.put(5, 1, "Параметры:")
sheet.put(5, 3, "Валюта БУ, НУ: Руб")
label_col, first_sum = 4, 5
top = 7
sheet.put(top, 1, "Счет")
if indicator_column:
sheet.put(top, label_col, INDICATOR_TITLE)
sheet.merge(top, label_col, top + 1, label_col)
for index, key in enumerate(("opening", "turnover", "closing")):
col = first_sum + 2 * index
sheet.put(top, col, GROUP_TITLES[key])
sheet.merge(top, col, top, col + 1)
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
sheet.put(top + 1, 1, group_label)
for row, item in enumerate(rows, start=top + 2):
if item.text == MERGED:
sheet.merge(row - 1, 1, row, 1)
elif item.text:
sheet.put(row, 1, item.text)
if item.label is not None:
sheet.put(row, label_col, item.label)
if item.outline:
sheet.outline[row] = item.outline
for offset, value in enumerate(item.sums):
if value is not None:
sheet.put(row, first_sum + offset, D(value))
return sheet
```
(Если `Sequence` не импортирован в `generate.py` — добавить в импорт `collections.abc`.)
- [ ] **Step 2: Четыре файла** (суммы согласованы: родитель БУ = Σ детей БУ, «Итого» = корень, тождество строк выполняется у всех строк):
```python
def _s33() -> list[SheetSpec]:
"""Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки
БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними."""
rows = [
GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1),
GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1),
GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1),
GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1),
GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
]
return [_grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True)]
def _s34() -> list[SheetSpec]:
"""Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1),
GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1),
GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1),
GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1),
GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1),
GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)]
def _s35() -> list[SheetSpec]:
"""«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне."""
rows = [
GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1),
GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2),
GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3),
GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3),
GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3),
GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3),
GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
]
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)]
def _s36() -> list[SheetSpec]:
"""Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением."""
rows = [
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1),
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1),
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
]
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)]
```
Добавить в `FILES`:
```python
"s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx),
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx),
```
и поправить описание CLI в `main` («s01…s36»). Номера s37–s44 заняты треком B (#59); рулинг владельца R-1.
- [ ] **Step 3: Сгенерировать в fixtures:** `cd $WT/tabular-parser/api && .venv/bin/python -m tests.synthetic.generate $WT/fixtures/osv/synthetic` — проверить `git -C $WT/fixtures status`: изменены только четыре новых файла (s01–s32 побайтно те же; если нет — стоп, отчёт).
- [ ] **Step 4: Ожидания и сценарии** в `test_synthetic_files.py`:
- `Expect.data_type: str | None = "BU"`;
- `EXPECTED` — `"s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None)`, `"s34_indicator_column_qty_val_diff.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)`; `EXPECTED_NAMES` — четыре имени; докстринг `test_synthetic_set_is_complete` — «s01…s36»;
- сценарии (по образцу `test_s30_…`, хелперы `one_document`, `parse_named`, `warnings_of`):
```python
def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None:
"""AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
outcome = parse_named(fixtures_dir, "s33_indicator_column_bu_nu.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
nu = [row for row in doc.rows if row.data_type == "NU"]
assert [row.row_value for row in nu] == ["НУ"] * 4
assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu)
for row in nu:
if row.group_type != "TOTAL":
parent = by_row[row.parent_row_number]
assert parent.data_type == "BU" and parent.row_number == row.row_number - 1
assert row.level == parent.level + 1
assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"]
assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"}
assert doc.header.data_type is None
texts = warnings_of(outcome)
assert not any("ниже таблицы" in text for text in texts), texts
assert not any("приняты как БУ" in text for text in texts), texts
assert outcome.status is ResultStatus.DONE
def test_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
"""«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
outcome = parse_named(fixtures_dir, "s34_indicator_column_qty_val_diff.xlsx")
doc = one_document(outcome)
types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}}
assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None}
unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text]
assert len(unknown) == 1, warnings_of(outcome)
assert outcome.status is ResultStatus.DONE
def test_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
"""AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
outcome = parse_named(fixtures_dir, "s35_currency_slices.xlsx")
doc = one_document(outcome)
by_row = {row.row_number: row for row in doc.rows}
in_currency = [row for row in doc.rows if row.row_value == "В валюте"]
assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"]
assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"]
slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")]
assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")}
assert outcome.status is ResultStatus.DONE
def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
"""Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
outcome = parse_named(fixtures_dir, "s36_no_data_type_header.xlsx")
doc = one_document(outcome)
assert doc.header.data_type is None
assert {row.data_type for row in doc.rows} == {"BU"}
assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
assert outcome.status is ResultStatus.DONE
```
Если имена хелперов в модуле другие — взять существующие (`one_document`, `parse_named`, `warnings_of`, `ResultStatus` уже используются в s30/s31).
- [ ] **Step 5: Запустить — сценарии s33–s36 красные** (это и есть воспроизведение багов): `ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q -k "s33 or s34 or s35 or s36"`. Записать в ledger, как именно падает каждый (до исправления).
- [ ] **Step 6: Commit** в tabular-parser (`generate.py`, тесты; `make check` красный только на s33–s36 и покрытии словарей — допустимо с пометкой в ledger) и отдельный commit в fixtures (`git -C $WT/fixtures add osv/synthetic/s3[3-6]*.xlsx`).
---
### Task 3: Колонка «Показатели» в заголовке (модель: haiku)
**Files:**
- Modify: `api/src/parser/detect.py` (`TableHeader`, `find_table_header`)
- Test: `api/tests/unit/parser/test_detect.py`
- [ ] **Step 1: Тест** — сетка `gridbuilder` по образцу тестов `inn_col` в `test_detect.py`: заголовок «Счет | Показа-\nтели | Сальдо на начало …» → `header.indicator_col` = колонка «Показа-тели», `header.text_col` не меняется, колонка «Показатели» есть в `header.extra_cols`; без такой колонки — `indicator_col is None`.
- [ ] **Step 2: Запустить — падает.**
- [ ] **Step 3: Реализация.** `TableHeader` — последнее поле `indicator_col: int | None = None` (комментарий: «колонка «Показатели» — метка вида суммы строки, §1.3»). В `find_table_header` после `inn_col`:
```python
indicator_col = _service_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules, "indicator")
```
где `_service_col` — первая колонка между колонкой текста и суммами, чей текст в блоке заголовка матчит `rules.headers.service_columns.get(key, [])` (вынести общий цикл из `_inn_col` по заголовку, поведение `_inn_col` не менять). В `occupied` колонку **не** добавлять (остаётся в `extra_cols`). Передать `indicator_col=indicator_col` в итоговый `TableHeader`.
- [ ] **Step 4: Тест зелёный, `make check`, commit** «Заголовок ОСВ: колонка «Показатели» (#58)».
---
### Task 4: Строки-показатели при сборе строк (модель: sonnet)
**Files:**
- Modify: `api/src/parser/hierarchy.py` (`BodyRow` — только поля)
- Create: `api/src/parser/indicators.py`
- Modify: `api/src/parser/classify.py` (`_label_cols`, `_body_row`, `_trailing_totals`, `collect_body_rows`)
- Test: `api/tests/unit/parser/test_indicators.py`, `api/tests/unit/parser/test_classify.py`
- [ ] **Step 1: Поля `BodyRow`** (после `label_col`, с умолчаниями — позиционные конструкторы в тестах не ломаются):
```python
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
```
(`DataType` — из `src.contract.common`.)
- [ ] **Step 2: Модуль `indicators.py` — чтение метки:**
```python
"""Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных."""
from dataclasses import dataclass
from src.contract.common import DataType
from src.parser.detect import TableHeader
from src.parser.grid import Sheet
from src.parser.rules import Rules, first_match
@dataclass(frozen=True)
class RowLabel:
"""Вид строки по метке: показатель ли она, `data_type` метки, текст строки взамен исходного, предупреждение."""
indicator: bool
data_type: DataType | None
text: str | None = None # для метки колонки «Показатели» — сама метка (`row_value`)
warning: str | None = None
def column_label(sheet: Sheet, row: int, header: TableHeader) -> str:
"""Метка строки в колонке «Показатели» (пусто — колонки нет или ячейка пуста)."""
return "" if header.indicator_col is None else sheet.text_at(row, header.indicator_col)
def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Rules) -> RowLabel:
"""Метка колонки «Показатели» важнее текста строки: «БУ» — строка данных BU, прочие — показатели; без метки —
показатель по тексту («Вал.», «Кол.», «Кол-во», «В валюте», код валюты), иначе обычная строка без своего вида."""
label = column_label(sheet, row, header)
if label:
data_type = first_match(rules.data_types.indicator_column, label)
if data_type == "BU":
return RowLabel(indicator=False, data_type="BU")
if data_type is not None or any(p.search(label) for p in rules.data_types.indicator_column_untyped):
return RowLabel(indicator=True, data_type=data_type, text=label)
warning = f"строка {row}: метка «{label}» в колонке «Показатели» не распознана — строка не участвует в проверках"
return RowLabel(indicator=True, data_type=None, text=label, warning=warning)
data_type = row_data_type(text, rules, frozenset(rules.data_types.currency_codes))
return RowLabel(indicator=data_type is not None, data_type=data_type)
def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool:
"""Итоговая строка-показатель за «Итого»: метка колонки «Показатели» не «БУ»."""
label = column_label(sheet, row, header)
return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU"
```
`row_data_type` сейчас живёт в `classify.py`, а `classify.py` будет импортировать `indicators.py` — перенести `row_data_type` в `indicators.py` и в `classify.py` оставить реэкспорт (`from src.parser.indicators import row_data_type`), чтобы не ломать импорт в `inn_rows.py` и тестах; цикла импортов быть не должно (`indicators.py` не импортирует `classify`).
- [ ] **Step 3: Тесты `test_indicators.py`** (сетки `gridbuilder`; `TableHeader` — через `find_table_header`): «БУ» → `indicator False, data_type BU`; «НУ» → `True, NU, text "НУ"`; «ПР» → `True, None`, без предупреждения; «Ед.» → `True, None`, предупреждение с номером строки; без колонки, текст «В валюте» → `True, CURRENCY`; текст «Валюта USD» → `False, None`; `is_indicator_total` для «НУ» — `True`, для «БУ» — `False`.
- [ ] **Step 4: `classify.py`:**
- `_label_cols`: условие `col != header.inn_col` дополнить `and col != header.indicator_col`;
- `_body_row(sheet, row, header, label_cols, warnings, rules)`: после `_label_of` — `label = read_label(sheet, row, text, header, rules)`; если `label.text is not None` — `text = label.text` (у строки с меткой колонки текст — метка; `account` считать по новому тексту → не счёт); `label.warning` — в `warnings`; в `BodyRow` — `indicator=label.indicator, label_data_type=label.data_type`;
- `_trailing_totals`: строка продолжает серию, если `_is_secondary_total(...)` **или** `is_indicator_total(sheet, row, header, rules)`; докстринг — «и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613)»;
- `collect_body_rows` передаёт `rules` в `_body_row`.
- [ ] **Step 5: Тест в `test_classify.py`** — сетка с колонкой «Показатели» (строки БУ/НУ, объединённое название, «Итого» БУ+НУ): `collect_body_rows` — строки «НУ» с `indicator True`, `text == "НУ"`, `label_data_type == "NU"`; вторая «Итого» в `body`, предупреждения «ниже таблицы» нет.
- [ ] **Step 6: `make check`** (сценарии s33–s36 ещё могут быть красными — отметить в ledger), **commit** «Строки-показатели: метка колонки «Показатели» при сборе строк (#58)».
---
### Task 5: Иерархия — показатели вне каскада (модель: sonnet)
**Files:**
- Modify: `api/src/parser/indicators.py` (`levels_with_indicators`)
- Modify: `api/src/parser/osv.py` (`parse_sheet`)
- Modify: `api/src/parser/inn_rows.py` (`_kind`)
- Test: `api/tests/unit/parser/test_indicators.py`
- [ ] **Step 1: Тесты** на `levels_with_indicators` (строки `BodyRow` вручную, `Deadline` с большим запасом, по образцу `test_hierarchy.py`):
1. счёт (outline 0), строка «НУ»-показатель (outline 0), объект (outline 1), «НУ» (outline 1), «Итого», «Итого»-показатель → показатель счёта: родитель — счёт, уровень 1; показатель объекта: родитель — объект, уровень 2; объект — ребёнок счёта (каскад не видел показателей); обе итоговые — уровень 0 без родителя; стратегия `outline`;
2. показатель первой строкой таблицы — уровень 0 без родителя;
3. порядок ключей `by_row` и `parent` — порядок строк листа.
- [ ] **Step 2: Реализация** в `indicators.py`:
```python
def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
"""
levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline)
by_row: dict[int, int] = {}
parent: dict[int, int | None] = {}
previous: int | None = None
for item in rows:
if not item.indicator:
by_row[item.row], parent[item.row] = levels.by_row[item.row], levels.parent[item.row]
if total_row is None or item.row < total_row:
previous = item.row
continue
if previous is None or (total_row is not None and item.row >= total_row):
by_row[item.row], parent[item.row] = ROOT_LEVEL, None
else:
by_row[item.row], parent[item.row] = by_row[previous] + 1, previous
return Levels(by_row=by_row, parent=parent, strategy=levels.strategy)
```
(`ROOT_LEVEL`, `Levels`, `resolve_levels`, `BodyRow` — из `hierarchy.py`; `Deadline` — из `deadline.py`.)
- [ ] **Step 3: `osv.py`** — в `parse_sheet` `levels = resolve_levels(body, total_row, deadline)` заменить на `levels = levels_with_indicators(body, total_row, deadline)` (импорт `resolve_levels` убрать, если больше не нужен).
- [ ] **Step 4: `inn_rows._kind`** — `if row_data_type(item.text, rules, currency) is not None:` заменить на `if item.indicator:` (параметр `currency` убрать, если больше не используется, вместе с вызывающим кодом).
- [ ] **Step 5: `make check`** — все прежние тесты (в том числе s04, s30, `test_inn_rows*.py`) зелёные; **commit** «Строки-показатели вне каскада иерархии (#58)».
---
### Task 6: Классификация, проверки, вид данных по умолчанию (модель: sonnet)
**Files:**
- Modify: `api/src/parser/classify.py` (`ClassifiedRow`, `_Kind`, `_classify_row`, `classify_rows`)
- Modify: `api/src/parser/checks.py` (`_INDICATOR_DATA_TYPES`, `_is_indicator`, `_total`)
- Modify: `api/src/parser/osv.py` (предупреждение умолчания)
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_checks.py`
- [ ] **Step 1: Тесты:**
- `test_checks.py`: строка `ClassifiedRow` с `data_type="NU", indicator=True` под родителем BU не входит в сумму детей; документ из одних строк NU без признака (как «Выводимые данные: НУ») проверяется как обычно; `_total`/`total_matches_root` берёт первую итоговую строку без признака, даже если перед ней стоит итоговая строка-показатель;
- `test_classify.py`: строка с `label_data_type="BU"` при `doc_data_type="NU"` → BU; строка без метки при `doc_data_type=None` → BU; показатель → `OTHER`, `data_type` метки, `indicator=True`, `account_code_raw` ветки родителя, `counterparty_*` — `None`; итоговая строка-показатель → `TOTAL` c `data_type` метки и `indicator=True`.
- [ ] **Step 2: Реализация:**
- `ClassifiedRow`: поле `indicator: bool = False` (последним, с умолчанием);
- `_Kind`: поле `indicator: bool = False`;
- `_classify_row`: вместо `row_data_type(...)`:
```python
if item.indicator:
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
own = item.label_data_type or doc_data_type
```
и далее везде, где строка получала `doc_data_type`, — `own` (ACCOUNT, строка ИНН без пары, схема, COUNTERPARTY);
- `classify_rows`: параметр `doc_data_type` — уже с умолчанием (см. `osv.py`); для итоговых строк `_Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator)`; в `ClassifiedRow(...)` передать `indicator=kind.indicator`; докстринг — пункт про строки-показатели переписать по §1.3;
- `checks.py`: удалить `_INDICATOR_DATA_TYPES`; `_is_indicator(item)` → `return item.indicator`; `_total` — первая строка `TOTAL` с `not item.indicator`; докстринги `parent_sums`/`_level_zero` — «строки-показатели»;
- `osv.py` (`parse_sheet`): перед классификацией
```python
data_type = doc.data_type or DEFAULT_DATA_TYPE
defaulted = doc.data_type is None and any(
not item.indicator and item.label_data_type is None for item in body
)
```
`classify_rows(..., data_type, ...)`; после классификации, если `defaulted`, — `notes.append(OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING))` сразу после заметок шапки; константы в `osv.py`: `DEFAULT_DATA_TYPE: DataType = "BU"`, `DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"`. `header.data_type` (`_header(doc)`) не трогать.
- [ ] **Step 3: Синтетика s33–s36 зелёная:** `ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q`.
- [ ] **Step 4: `make check`, commit** «Классификация и проверки по признаку строки-показателя, BU по умолчанию (#58)».
---
### Task 7: Регрессия, golden, файлы контура (модель: sonnet)
**Files:**
- Modify: `$WT/fixtures/golden/expected_osv_parse/synthetic/s33…s36.json` (новые, `make golden-update`)
- [ ] **Step 1: `make golden-check`** из `$WT/tabular-parser`. Ожидание: diff только «нет снимка» для s33–s36. **Любой diff по реальным файлам или s01–s32 — стоп**: записать в ledger (файл, строки, что изменилось) и вернуть контроллеру; не перегенерировать.
- [ ] **Step 2: `make golden-update`**, проверить `git -C $WT/fixtures status` — только четыре новых снимка; просмотреть их глазами (строки НУ/«В валюте» — показатели с верными родителями, две TOTAL в s33). Commit в fixtures «Golden s33–s36: колонка «Показатели», «В валюте» (#58)».
- [ ] **Step 3: Покрытие словарей** — `ENV_FILE=.env.test .venv/bin/python -m pytest tests/fixtures/test_rules_coverage.py -q` зелёный (каждая новая запись срабатывает на s33–s35).
- [ ] **Step 4: Файлы контура (не в git).** `TMP=$(mktemp -d)`; извлечь из `/Users/andrey.epifancev/projects/awp-ca/result_dir.zip` файлы `files/3da66219-*.xlsx`, `files/86353387-*.xls` и все прочие файлы, где в заголовке таблицы есть «Показа» (найти перебором через `python -I`, без записи в репозитории), а также `files/270b41da-*.xlsx`, `files/738bfcf5-*.xlsx`. Для каждого: `cd $WT/tabular-parser/api && .venv/bin/python -m src.parser.cli "$TMP/<файл>"` на ветке и на `origin/main` (второй worktree не создавать: сравнить с выдачей `admin/1639-contour-run/parser-out/<id>*.json`, если она есть). Свести в ledger таблицу: файл, статус до/после, `parent_sum_violations` до/после, число строк NU/QUANTITY/CURRENCY, оборот Дт корня по BU. Ожидание по AURORA-1613: оборот по дебету БУ без удвоения. Для 270b41da/738bfcf5 (плоская форма, трек B) — только что строки «В валюте» стали CURRENCY; структура плоской формы — не цель этого плана. `rm -rf "$TMP"` в конце.
- [ ] **Step 5:** если на файлах контура найден случай, который план не покрывает (новая метка, иная раскладка), — не чинить, записать находкой `O-n` в ledger и вернуть контроллеру.
---
### Task 8: Документация и итоговые проверки (модель: sonnet)
**Files:**
- Modify: `README.md` (раздел «Парсер ОСВ»: строки-показатели, колонка «Показатели», BU по умолчанию)
- Modify: `openspec/changes/osv-indicator-column/tasks.md` (отметить выполненное)
- Modify: `$WT/docs/context/06-test-data.md` (§7 синтетики: s33–s36 одной строкой каждый)
- Modify: `CLAUDE.md` tabular-parser (одна строка в «Модули»: `indicators.py` — строки-показатели)
- [ ] **Step 1:** README и CLAUDE.md — коротко, по образцу соседних абзацев; `06-test-data.md` — commit в docs (ветка `feat/58-osv-indicators`, там уже правка §1.3/§1.4).
- [ ] **Step 2:** `openspec validate osv-indicator-column --strict` — valid.
- [ ] **Step 3:** `make check-full` из `$WT/tabular-parser` — зелёный; вывод — в ledger.
- [ ] **Step 4:** commit «Документация: строки-показатели ОСВ (#58)».
## После плана (контроллер)
Финальное ревью ветки — opus; затем finishing-a-development-branch: PR в tabular-parser (Closes #58), docs, fixtures (fixtures сливается первым, затем tabular-parser, затем docs). Список «Rulings I made» и таблица находок — в описание PR tp. Jira AURORA-1613/1661 — не трогать: статусы ведёт главная сессия.

View File

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

View File

@ -0,0 +1,119 @@
# Design: колонка «Показатели» и валютные строки ОСВ
Правило — `05-domain-rules.md` §1.3 (абзацы «строки-показатели», «Колонка «Показатели»»), §1.4 (сверка родителя),
ветка docs `feat/58-osv-indicators`.
## Как выглядит файл (реальный образец контура, в git не кладётся)
`result_dir.zip`, `files/3da66219-….xlsx` — ОСВ по счёту 02, шапка без «Выводимые данные» («Параметры: Валюта БУ, НУ:
Руб»):
```
r7 Счет | Показа-\nтели | Сальдо на начало | Обороты за период | Сальдо на конец
r8 Основные средства | | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r10 02 | БУ | | 480896,82 | 45754,41 | 46336,70 | | 481479,11 outline 0
r11 | НУ | | 480896,82 | 45754,41 | 46336,71 | | 481479,12 outline 0
r12 Интерактивная … | БУ | … outline 1
r13 | НУ | … outline 1
r16 Машина … (A16:D17 объединены) | БУ | … outline 1
r17 (продолжение объединения) | НУ | … outline 1
r30 Итого (A30:D31 объединены) | БУ | … outline 0
r31 (продолжение) | НУ | … outline 0
```
`files/270b41da-….xlsx` (плоская форма, трек B) — пары строк «Валюта CNY» (рубли) / «В валюте» (сумма в валюте) под
субсчётом 76.55.
Сейчас: колонка «Показа-тели» попадает в `extra_cols` и в `_label_cols` (r11, r13 — пустой текст → текст «НУ» из
колонки E, отступ +1000 → ребёнок строки БУ); r17 — текст объединённой ячейки (повтор названия), `data_type` всех строк
`null` (шапка молчит); r31 — не «Итого развернутое», `_trailing_totals` обрывается, строка уходит в подвал с
предупреждением «ниже таблицы есть числа».
## Решения
### Признак строки-показателя — поле строки, не значение `data_type`
Сейчас «показатель» = `data_type ∈ {CURRENCY, QUANTITY}` (`checks._INDICATOR_DATA_TYPES`). NU в этот набор добавить
нельзя: документ с «Выводимые данные: НУ» (s12) целиком состоит из строк NU, и все его проверки пропали бы. Поэтому
вводится признак `indicator: bool` у `BodyRow` (вычисляется при сборе строк) и у `ClassifiedRow`; `checks._is_indicator`
читает признак. Строка-показатель — если:
1. в листе есть колонка «Показатели», ячейка строки в ней непуста и метка ≠ «БУ» (словарь
`data_types.indicator_column`); `data_type` — из словаря; «ПР», «ВР» — `null` (словарь `indicator_column_untyped`);
метка не из обоих словарей — `null` + предупреждение `OSV_ROWS` «строка N: метка «X» в колонке «Показатели» не
распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.row` («Вал.», «Кол.», «Кол-во», **«В валюте»**) или равен коду валюты
(как сейчас, `row_data_type`).
Метка «БУ» в колонке даёт строке `data_type = BU` явно (даже если шапка молчит или называет НУ — колонка точнее шапки).
### Текст строки с меткой колонки — метка
У строки-показателя из колонки текст в колонке текста либо пуст, либо повторяет объединённую ячейку строки БУ (r17).
`BodyRow.text` = метка («НУ»), `is_account = False`. Так `row_value` не дублирует название объекта, а ИНН-логика и
классификатор не принимают строку за счёт или контрагента. Колонка «Показатели» исключается из `_label_cols` (как колонка
ИНН) и остаётся в `extra` под своим заголовком.
### Иерархия: показатели вне каскада, родитель — предыдущая строка данных
Новый модуль `src/parser/indicators.py` (чтобы не пересекаться с треком B в `hierarchy.py`):
`levels_with_indicators(rows, total_row, deadline) -> Levels`: функция сама запускает каскад `resolve_levels` на
строках без показателей (в `OsvParser.parse_sheet` вызывается она одна); затем каждому показателю (до «Итого») назначается родитель — ближайшая строка выше, которая не
показатель, — и уровень `level(родителя) + 1`; показатель в начале таблицы без строки данных выше — уровень 0 без
родителя. Итоговые строки-показатели — уровень 0 без родителя (как «Итого»). Стратегия иерархии (`Levels.strategy`) —
стратегия каскада.
Для строк «Вал.»/«Кол.» под контрагентом (s04, s30, реальные файлы) результат совпадает с прежним: 1С печатает
показатель сразу под строкой, к которой он относится. Golden-проверка это подтверждает; любое расхождение golden на
реальных файлах — стоп и вопрос владельцу (не перегенерировать молча).
### Итоговые строки-показатели
`_trailing_totals` продолжает серию итоговых строк и через строки с меткой показателя в колонке «Показатели»
(r31 после r30): они остаются в таблице, классифицируются TOTAL с `data_type` метки и `indicator = True`;
`checks._total` берёт первую итоговую строку **не показатель**. Трек B добавляет `_leading_totals` и меняет
`_total` под «Итого развернутое» перед «Итого» (`_trailing_totals` не трогает) — при слиянии второй трек добавляет
своё условие к версии первого.
### Вид данных по умолчанию
Если шапка не называет вид данных (`doc.data_type is None`), строки без собственного вида (не показатели и без метки
«БУ») получают `BU`; один раз на лист — предупреждение `OSV_HEADER` «вид данных в шапке не указан — строки без метки
приняты как БУ». Если все строки данных размечены колонкой «Показатели», предупреждения нет (умолчание не
применялось). `header.data_type` остаётся `null`; существующее предупреждение шапки «тип выводимых данных … не найден»
сохраняется.
### Что не меняется
- `balance_identity` проверяет все строки, включая показатели (как сейчас у «Вал.»/«Кол.»): у НУ/количества тождество
«начало + обороты = конец» тоже выполняется.
- «Валюта <код>» — обычная строка OTHER (рубли): `row_data_type("Валюта USD")` = `None` уже сейчас; закрепляется тестом.
- Контракт, DBML, arm-core.
## Decisions made without discussion
| Дата | Решение | Обоснование | Что стоит ошибка |
|---|---|---|---|
| 19.10.2026 | «ПР», «ВР» и метка не из словаря — `data_type = null`, строка-показатель | В контракте нет значения для разниц; `null` ядро не берёт в свод, проверки строку не видят | Разницы не видны как отдельный вид данных до AURORA-1669; переразметка — правка словаря |
| 19.10.2026 | Признак показателя — поле `indicator`, а не набор значений `data_type` | Документ «Выводимые данные: НУ» не должен терять проверки | Лишнее поле в двух dataclass; контракт не меняется |
| 19.10.2026 | `row_value` строки с меткой колонки — метка, а не текст колонки текста | Текст там пуст или повтор объединённой ячейки строки БУ | Если 1С когда-то печатает в строке НУ своё название — оно теряется (не встречалось в 31 файле контура) |
| 19.10.2026 | Колонка «Показатели» остаётся в `extra` | Первичка не теряется, ядро `extra` хранит как есть | Дублирование метки в `extra` и `data_type` |
| 19.10.2026 | Родитель показателя — предыдущая строка данных для всех показателей, включая «Вал.»/«Кол.» в колонке текста | Один механизм для колонки, «В валюте» и старых меток; каскад не видит строк, которые ломают дерево | Если показатель стоит не сразу под своей строкой, родитель будет неверным; ловит golden |
| 19.10.2026 | Умолчание BU — только строкам без собственного вида; `header.data_type` остаётся `null` | Шапка действительно молчит; ядру нужен вид строки, не шапки | Предупреждение на листах без «Выводимые данные» (были без него) |
| 19.10.2026 | Синтетика s33–s36 (R-1: s40–s44 заняты треком B #59, s37–s39 резерв) | Рулинг владельца R-1: параллельные треки не конфликтуют по именам файлов набора | Пропуск номеров в наборе |
| 19.10.2026 | Новый модуль `indicators.py` вместо правки `hierarchy.resolve_levels` | Трек B меняет каскад иерархии параллельно | Ещё один модуль конвейера |
| 19.10.2026 | T1.1: в колонке «Показатели» метка «Вал» без точки = CURRENCY (`^вал\.?$`), хотя в тексте строки (`row`) «Вал» без точки — не метка (материал, AURORA-1663) | В колонке меток закрытый набор | Ложный показатель для метки «Вал» вместо БУ |
| 19.10.2026 | T4.1: любая метка колонки «Показатели», кроме «БУ», у строки сразу после «Итого» делает её итоговой строкой-показателем (включая неизвестную метку) | Показатели не должны превращаться в «хвост ниже таблицы»; предупреждение о неизвестной метке выдаёт `read_label` | Неизвестная метка у реальной строки ниже таблицы будет втянута в таблицу |
| 19.10.2026 | R-2: трейлер коммитов «Claude Sonnet 5.5» вместо «Claude Opus 5.5» из плана | Атрибуция сессии | Правка трейлеров |
| 19.10.2026 | R-3: Task 3 плана (haiku) исполнен sonnet | Проза с рефакторингом | Стоимость |
| 19.10.2026 | R-4: мелкие находки T6.1/T6.3 исправлены до записи golden, а не отложены | Golden пишется один раз на исправленном коде | Лишний коммит |
| 19.10.2026 | R-5: golden s34 обновлён владельцем вручную после отказа классификатора в `make golden-update` | Снимок s34 — собственный новый снимок ветки; изменилась одна строка (текст предупреждения о неизвестной метке по I-2) | Ручное обновление без агентского ревью diff; diff проверен контроллером: одна строка |
Решения владельца 19.10.2026 (чат): п. 1 колонка «Показатели» как метки §1.3, НУ без своих проверок; п. 2 «В валюте» →
CURRENCY, «Валюта <код>» — строка OTHER в рублях, код валюты не хранится; п. 3 BU по умолчанию с предупреждением — по
рекомендации агента, владелец принял без возражений («ошибки давай исправлять сейчас»).
Решение владельца 19.10.2026 (O-1 из #58): счёт 90 добавлен в список счетов без сальдо по аналитике
(`accounts_without_analytic_balance`, OQ-132; было «90 не включать»). Причина: четыре файла контура с колонкой
«Показатели» оставались `needs_check` на счёте 90 (667d1ee1, 7983e91b, 3e74526e, ec075a82): у родителей 90.xx.y сальдо на
конец есть, у аналитики — нет. O-1 — fixed 1d2a46f.

View File

@ -0,0 +1,51 @@
# Колонка «Показатели» и валютные строки ОСВ (AURORA-1613, AURORA-1661 — валютная часть)
Issue: RSHB/arm-tabular-parser#58. Ветки `feat/58-osv-indicators` в tabular-parser, docs, fixtures.
## Why
Прогон выгрузки тестового контура (AURORA-1639, свод `admin/1639-contour-run/`, находки O-7, O-8) и тестирование
AURORA-1534 показали два дефекта одного механизма — вида данных строки ОСВ:
- **AURORA-1613.** 1С печатает вид суммы строки отдельной колонкой «Показатели» (БУ / НУ / Кол. / Вал. / ПР / ВР;
31 файл контура). Парсер о колонке не знает: метка уходит в `extra`, `data_type` строк НУ/Кол. — `null` (шапка без
«Выводимые данные»); строка НУ с пустым текстом получает текст из колонки «Показатели» и отступ «колонка глубже»,
становится ребёнком строки БУ — «дети = родитель» и «Итого» не сходятся, документ `needs_check`, на экране оборот
удваивается. Вторая строка «Итого» (по НУ) обрезается как подвал с предупреждением «ниже таблицы есть числа».
- **AURORA-1661, валютная часть.** Строки «В валюте» не распознаются как валютные: суммы в валюте (69,6 млн USD)
записываются как рублёвые и попадают в свод.
- Строки без вида данных (шапка без «Выводимые данные», нет метки) получают `data_type = null`, а ядро берёт в свод
только BU/SUM — строки молча пропадают из сводной ОСВ.
Решения владельца 19.10.2026 — минимальное исправление: строки, которые не являются БУ, изолируются от данных БУ.
Глубокая аналитика НУ и валют вынесена в AURORA-1669 и AURORA-1670 (бэклог аналитики).
## What Changes
- Колонка «Показатели» определяется по заголовку (словарь `service_columns.indicator`); метка строки читается по
новому словарю `data_types.indicator_column`: «БУ» → BU (обычная строка), «НУ» → NU, «Кол.» → QUANTITY, «Вал.» →
CURRENCY, «ПР»/«ВР»/метка не из словаря → `data_type = null` (метка не из словаря — с предупреждением).
- Понятие **строки-показателя** (`indicator`) — признак строки, а не значение `data_type`: строки-показатели в колонке
текста («Вал.», «Кол.», «Кол-во», код валюты, **новое** «В валюте») и строки с меткой колонки «Показатели», кроме
«БУ». Строки-показатели не передаются в каскад иерархии; родитель — предыдущая строка данных, уровень на один
глубже. В проверки «дети = родитель» и «Итого» не входят (как сейчас CURRENCY/QUANTITY); тождество строки — как у всех.
- `row_value` строки с меткой колонки — метка (а не повтор текста объединённой ячейки строки БУ).
- Итоговая строка с меткой показателя сразу за «Итого» — TOTAL, в подвал не уходит, в сверку не входит.
- Строки без вида данных при шапке без «Выводимые данные» — `data_type = BU` с предупреждением; `header.data_type`
остаётся `null` (шапка его не называет).
- «Валюта <код>» — обычная строка (OTHER, рубли): поведение не меняется, закрепляется тестом.
- §1.3/§1.4 `05-domain-rules.md` — отдельным PR в arm-ka-docs (ветка `feat/58-osv-indicators`).
- Синтетика s33–s36 и golden — PR в arm-ka-fixtures.
Затронутые репозитории: arm-tabular-parser (код, словари, тесты, спека `osv-parsing`), arm-ka-docs (§1.3, §1.4),
arm-ka-fixtures (синтетика, golden). Таблицы DBML v5.19 не меняются (`osv_entry.data_type` уже включает NU, CURRENCY,
QUANTITY, NULL); контракт `recognition-result` не меняется. Срез: поддержка после Среза 2a (ошибки контура).
## Non-goals
- Собственные проверки по НУ, итоги по видам данных, хранение «ПР»/«ВР» отдельными значениями — AURORA-1669.
- Код валюты строки, мультивалютные контрагенты, пересчёт; пересмотр «USD» как CURRENCY (в ТЗ — разрез с рублями) —
AURORA-1670.
- Плоская форма 1С (шапка «по счету: X», плоский список без строки счёта, «Итого развернутое» перед «Итого»,
контрагенты на расчётных счетах) — отдельный трек AURORA-1661 (`.wt/osv-flat/`).
- Изменения arm-core: ядро уже берёт в свод только BU/SUM и пишет `data_type` как пришёл.

View File

@ -0,0 +1,192 @@
## ADDED Requirements
### Requirement: Строки-показатели и колонка «Показатели»
Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать
строку показателем, если:
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.» →
QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`)
→ `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в
колонке «Показатели» не распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «В валюте» → CURRENCY/QUANTITY) или
равен коду валюты из `currency_codes`.
Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты
не хранится. Колонка «Показатели» MUST NOT быть колонкой текста строки (как колонка ИНН) и SHALL оставаться в `extra`
строки под своим заголовком. `row_value` строки с меткой колонки — сама метка.
Строки-показатели MUST NOT передаваться в каскад иерархии. Родитель строки-показателя — ближайшая строка выше, которая
сама не показатель; уровень — на один глубже родителя; показатель без строки данных выше — уровень 0 без родителя.
Строки-показатели MUST NOT входить в сверку «сумма детей = родитель» (ни как дети, ни как родители) и в сверку «Итого»;
тождество строки (начало + обороты = конец) проверяется у них, как у всех строк. Строки NU хранятся как пришли
(`ASSUMPTION(OQ-45)`); собственных проверок по NU нет (AURORA-1669).
Итоговые строки, следующие сразу за основной «Итого» и помеченные в колонке «Показатели» меткой показателя, SHALL
оставаться в таблице как TOTAL (`data_type` по метке) и MUST NOT уходить в подвал или давать предупреждение «ниже
таблицы есть числа»; сверка «Итого = корень» берёт первую итоговую строку, которая не показатель.
#### Scenario: Колонка «Показатели» БУ/НУ
- **WHEN** разбирается синтетическая ОСВ `s33` (счёт 02, шапка без «Выводимые данные», колонка «Показа-тели», на каждую
строку — «БУ» и «НУ», название объединено на две строки, «Итого» БУ и НУ)
- **THEN** строки «БУ» — `data_type = BU`, строки «НУ» — `data_type = NU`, `row_value = "НУ"`, `group_type = OTHER`,
родитель — строка «БУ» над ней; `parent_sum_violations = []`, `total_row_matches_root = true`, две строки TOTAL
(BU и NU), предупреждения «ниже таблицы» и «вид данных … приняты как БУ» нет, статус `done`
#### Scenario: Количество, валюта и разницы в колонке «Показатели»
- **WHEN** разбирается синтетическая ОСВ `s34` (колонка «Показатели» с метками «Кол.», «Вал.», «ПР», «ВР» и одной
неизвестной меткой)
- **THEN** «Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная — `data_type = null`, все — показатели вне
сверки родителя; одно предупреждение `OSV_ROWS` о нераспознанной метке; статус `done`
#### Scenario: «В валюте» под «Валюта <код>»
- **WHEN** разбирается синтетическая ОСВ `s35` (под контрагентом пары «Валюта USD» / «В валюте», «Валюта EUR» /
«В валюте»)
- **THEN** «В валюте» — CURRENCY, родитель — «Валюта …» над ней; контрагент = Σ строк «Валюта …» по рублям;
статус `done`
#### Scenario: Строки «Вал.»/«Кол.» в колонке текста не меняются
- **WHEN** разбираются s04, s30 и все реальные фикстуры `../fixtures/osv/**`
- **THEN** golden-снимки совпадают с прежними (родитель показателя — строка над ним, как и было)
## MODIFIED Requirements
### 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; строка ИНН без пары — OTHER;
последняя строка уровня 0 с текстом из словаря «Итого» — 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` шапки, строка участвует в сверке «сумма детей = родитель»
### 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); итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корня нет (пустая ОСВ,
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 подгонять
структуру под сходимость.
Исключение — **счета без сальдо по аналитике** (решение владельца 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=…` нет

View File

@ -0,0 +1,32 @@
# Tasks: колонка «Показатели» и валютные строки ОСВ
План исполнения — `docs/superpowers/plans/2026-10-19-osv-indicator-column.md` (задачи 1–8, модели исполнителей там).
## 1. Словари
- [x] 1.1 `api/rules/osv_headers.yaml` — `service_columns.indicator`; `api/rules/data_types.yaml` — `indicator_column`,
`indicator_column_untyped`, `row: «В валюте»`; `src/parser/rules.py` — поля `DataTypeRules`; тесты `test_rules.py`
## 2. Синтетика
- [x] 2.1 `tests/synthetic/generate.py` — s33–s36 (`_grid_osv`); `tests/synthetic/test_synthetic_files.py` — ожидания
и сценарии (сначала красные); файлы в `../fixtures/osv/synthetic/`
## 3. Колонка «Показатели» в заголовке
- [x] 3.1 `src/parser/detect.py` — `TableHeader.indicator_col`; тест `test_detect.py`
## 4. Строки-показатели при сборе строк
- [x] 4.1 `src/parser/hierarchy.py` (`BodyRow`), новый `src/parser/indicators.py` (`read_label`), `src/parser/classify.py`
(`collect_body_rows`, `_label_cols`, `_trailing_totals`); тесты `test_classify.py`, `test_indicators.py`
## 5. Иерархия
- [x] 5.1 `indicators.levels_with_indicators`, `osv.py` (вызов), `inn_rows._kind`; тест `test_indicators.py`
## 6. Классификация и проверки
- [x] 6.1 `classify.py` (`_Kind`, `ClassifiedRow.indicator`, вид данных строки), `checks.py` (`_is_indicator`, `_total`),
`osv.py` (предупреждение «приняты как БУ»); тесты `test_classify.py`, `test_checks.py`
## 7. Регрессия и реальные файлы
- [x] 7.1 Синтетика зелёная; `make golden-check` — снимки реальных файлов и s01–s32 без изменений; `make golden-update`
только для s33–s36; локальная сверка файлов контура (не в git)
## 8. Документация и проверки
- [x] 8.1 README (раздел «Парсер ОСВ»), `../docs/context/06-test-data.md` §7 (s33–s36), `make check-full`