Строки-показатели ОСВ: колонка «Показатели», «В валюте», BU по умолчанию (#58) #60
@ -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`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
|
||||||
|
|||||||
31
README.md
31
README.md
@ -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`), а
|
||||||
|
|||||||
@ -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`) |
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
83
api/src/parser/indicators.py
Normal file
83
api/src/parser/indicators.py
Normal 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)
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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]]
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
138
api/tests/unit/parser/test_indicators.py
Normal file
138
api/tests/unit/parser/test_indicators.py
Normal 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]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|||||||
551
docs/superpowers/plans/2026-10-19-osv-indicator-column.md
Normal file
551
docs/superpowers/plans/2026-10-19-osv-indicator-column.md
Normal 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 — не трогать: статусы ведёт главная сессия.
|
||||||
2
openspec/changes/osv-indicator-column/.openspec.yaml
Normal file
2
openspec/changes/osv-indicator-column/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-19
|
||||||
119
openspec/changes/osv-indicator-column/design.md
Normal file
119
openspec/changes/osv-indicator-column/design.md
Normal 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.
|
||||||
51
openspec/changes/osv-indicator-column/proposal.md
Normal file
51
openspec/changes/osv-indicator-column/proposal.md
Normal 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` как пришёл.
|
||||||
192
openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md
Normal file
192
openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md
Normal 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=…` нет
|
||||||
32
openspec/changes/osv-indicator-column/tasks.md
Normal file
32
openspec/changes/osv-indicator-column/tasks.md
Normal 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`
|
||||||
Loading…
x
Reference in New Issue
Block a user