diff --git a/CLAUDE.md b/CLAUDE.md index 5d880ed..6759afb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре. - `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.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` — разбор файла. - `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера. @@ -75,7 +75,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`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`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не @@ -85,9 +85,9 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто). Это решение зафиксировано в 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 с; кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют diff --git a/README.md b/README.md index 1482637..25c550f 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,9 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | | `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | +| `indicators.py` | строки-показатели (§1.3): метка вида суммы из колонки «Показатели» или текста строки (`read_label`), привязка показателя к строке данных над ним (`levels_with_indicators`) | | `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`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики | | `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) | | `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) | @@ -153,15 +154,29 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо `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) -Допуск ±0.01, пустое значение = 0. Строки «Вал.», «Кол.» и код валюты (`data_type = CURRENCY | QUANTITY`) в суммирование -не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по +Допуск ±0.01, пустое значение = 0. Строки-показатели (признак строки: НУ/ПР/ВР/Кол./Вал./«В валюте»/код валюты, метка колонки «Показатели» или +текст) в суммирование не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень». -**«Итого».** Основная итоговая строка — первая TOTAL листа; она сверяется с первым корнем ACCOUNT либо, если корней +**«Итого».** Основная итоговая строка — первая TOTAL, не являющаяся показателем; она сверяется с первым корнем ACCOUNT либо, если корней уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм @@ -191,10 +206,10 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает). -**Счета без сальдо по аналитике (OQ-132).** У 91 и 99 строки аналитики несут только обороты, сальдо закрывается на +**Счета без сальдо по аналитике (OQ-132).** У 90, 91 и 99 строки аналитики несут только обороты, сальдо закрывается на субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы `needs_check`. -Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (начальный состав `91`, `99`; -90 не входит — внутри года у его субсчетов сальдо есть). Строка относится к счёту списка по коду ближайшего счёта её ветки: +Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (состав `90`, `91`, `99`; +счёт 90 добавлен решением владельца 19.10.2026, O-1 из #58). Строка относится к счёту списка по коду ближайшего счёта её ветки: код совпадает с записью или является её субсчётом (`91`, `91.01`, но не `910`). Сверка «сумма детей = родитель» остаётся: обороты — всегда, сальдо — только у родителя с дочерними субсчетами (корень → 91.01/91.02/91.09); у родителя с одной аналитикой сальдо не сверяется. «Итого» сверяется как обычно. @@ -378,7 +393,7 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки — для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check` завершается кодом 1. -Каталог `fixtures/osv/synthetic/` — 19 синтетических файлов `s01`–`s19` (генератор — `tests/synthetic/generate.py`): +Каталог `fixtures/osv/synthetic/` — синтетические файлы `s01`–`s36` (генератор — `tests/synthetic/generate.py`): форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.). Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml` сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а diff --git a/api/rules/README.md b/api/rules/README.md index bcfd9f8..536b5ed 100644 --- a/api/rules/README.md +++ b/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_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` — конец текста периода в строке шапки («г.», «год») | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml index 8eba216..7b5d623 100644 --- a/api/rules/data_types.yaml +++ b/api/rules/data_types.yaml @@ -18,6 +18,22 @@ standalone_header: row: - ['^вал\.$', CURRENCY] - ['^кол(ичество|\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` currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED] diff --git a/api/rules/osv_checks.yaml b/api/rules/osv_checks.yaml index 61d634c..58056db 100644 --- a/api/rules/osv_checks.yaml +++ b/api/rules/osv_checks.yaml @@ -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: + - '90' - '91' - '99' diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 85d65dd..375d300 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -31,12 +31,16 @@ debit: credit: - '^(кредит|кт|к т)\b' -# Служебные колонки таблицы по тексту заголовка. Код знает только колонку ИНН; прочие колонки таблицы (КПП, код, +# Служебные колонки таблицы по тексту заголовка. Код знает колонки ИНН и «Показатели»; прочие колонки таблицы (КПП, код, # количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют. # (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.) service_columns: inn: - '\bинн\b' + # Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР + # (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column` + indicator: + - '^показа\s?тели$' # Итоговая строка таблицы (начало текста первой колонки) total_row: diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 92b53e5..3d85650 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -4,19 +4,20 @@ 1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit = closing_debit − closing_credit`; -2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.») - в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) — - по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто - `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех - реальных файлах, где у счёта есть субсчета с разными сторонами сальдо); +2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки-показатели (`indicator`: «Вал.», «Кол.», + НУ и прочие метки колонки «Показатели») в суммирование не входят и сами родителями не проверяются. + Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя + сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым + сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с + разными сторонами сальдо); 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, - результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. + результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы. -Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только -обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2 +Счета без сальдо по аналитике (90, 91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут +только обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2 обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется. @@ -45,8 +46,6 @@ TOLERANCE = Decimal("0.01") # Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks` WARNING_ROWS_SHOWN = 20 _ZERO = Decimal(0) -# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят -_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"}) _ROOT_LEVEL = 0 # Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом _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: - return item.data_type in _INDICATOR_DATA_TYPES + return item.indicator 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` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются. - Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не + Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не проверяется. Родитель счёта без сальдо по аналитике (`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: - """Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит).""" - return next((item for item in rows if item.group_type == "TOTAL"), None) + """Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" + return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None) def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index e105256..eddb1a6 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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.grid import Sheet, column_letter 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.rules import GroupType, Rules, first_match, normalize_text +from src.parser.rules import GroupType, Rules __all__ = [ "ACCOUNT_RE", @@ -42,7 +43,6 @@ __all__ = [ "RowGroupType", "classify_rows", "collect_body_rows", - "row_data_type", ] # Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или @@ -82,6 +82,7 @@ class ClassifiedRow: counterparty_inn: str | None data_type: DataType | 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: - """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет).""" + """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет): + «Итого развернутое» и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613).""" last = total 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 last = row return last @@ -233,7 +236,9 @@ def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple found.update( col 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)) @@ -247,8 +252,15 @@ def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, s 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 = 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) bold, indent = _text_cell_style(sheet, row, label_col) # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже @@ -264,6 +276,8 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[ outline=sheet.outline(row), sums=_sums(sheet, row, header.columns, warnings), 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): if index % DEADLINE_CHECK_EVERY_ROWS == 0: 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 @@ -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 -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) class _Branch: """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта.""" @@ -362,6 +368,7 @@ class _Kind: data_type: DataType | None counterparty_name: str | None = None counterparty_inn: str | None = None + indicator: bool = False @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]: """Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей.""" - # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть - doc_data_type = context.doc_data_type + code = inherited.account_code_raw + 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 if account is not None: code = account.group(1) _, name = _schema_group(context.schema, _ACCOUNT_POSITION) - return _Kind("ACCOUNT", name, code, doc_data_type), _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 + return _Kind("ACCOUNT", name, code, own), _Branch(code, 0) if item.row in context.unpaired: # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте - return _Kind("OTHER", None, code, doc_data_type), inherited + return _Kind("OTHER", None, code, own), inherited position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) 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 column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) counterparty, in_name = split_inn_suffix(item.text, context.rules) 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): всё, что нужно классификатору, приходит явно @@ -430,8 +437,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta - «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя; в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); - счёт (`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; за схемой — OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); - строка ИНН без пары (`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: # Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_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 else: parent = levels.parent[item.row] @@ -466,6 +477,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta counterparty_inn=kind.counterparty_inn, data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), + indicator=kind.indicator, ) ) return result diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 3ff809b..5e1359b 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -122,6 +122,7 @@ class TableHeader: right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет) 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 -def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None: - """Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр.""" - inn_patterns = rules.headers.service_columns["inn"] +def _service_col(sheet: Sheet, block: range, text_col: int, first_sum: int, patterns: Iterable[Pattern]) -> int | None: + """Первая колонка между текстом и суммами, чей нормализованный заголовок в блоке матчит `patterns`.""" for row in block: 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 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]] = {} for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS): 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) 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) + 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, {}, []) 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, extra_cols=dict(sorted(extra_cols.items())), group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules), + indicator_col=indicator_col, ) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 96a7185..af1836a 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Protocol +from src.contract.common import DataType from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError @@ -59,6 +60,8 @@ class BodyRow: outline: int sums: tuple[Decimal | None, ...] label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) + indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики + label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») @dataclass(frozen=True) diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py new file mode 100644 index 0000000..13fc7ac --- /dev/null +++ b/api/src/parser/indicators.py @@ -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) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 3ecc22b..42ceb5c 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -18,7 +18,6 @@ from decimal import Decimal from typing import Literal from src.contract.result import OsvGroup -from src.parser.classify import row_data_type from src.parser.grid import Cell, Sheet from src.parser.header import InnHint from src.parser.hierarchy import BodyRow, Levels @@ -104,7 +103,6 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст hint: InnHint | None, rules: Rules, ) -> _Tree: - currency = frozenset(rules.data_types.currency_codes) children: dict[int | None, list[int]] = {} for item in rows: children.setdefault(levels.parent[item.row], []).append(item.row) @@ -112,7 +110,7 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст for siblings in children.values(): tree.position.update((row, index) for index, row in enumerate(siblings)) 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": # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения for item in rows: @@ -129,14 +127,13 @@ def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от sheet: Sheet, hint: InnHint | None, rules: Rules, - currency: frozenset[str], tree: _Tree, ) -> Kind: if total_row is not None and item.row >= total_row: return "total" if item.is_account: return "account" - if row_data_type(item.text, rules, currency) is not None: + if item.indicator: return "indicator" found = row_inn(_label_cell(sheet, item), rules) if found is None: diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index d4d1cc2..3c0809c 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -11,6 +11,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, replace from src.contract import OsvDocument, ResultWarning +from src.contract.common import DataType from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow from src.parser.checks import run_checks 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.grid import Sheet, column_letter 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.osv_warnings import ( OSV_HEADER, @@ -37,6 +38,8 @@ __all__ = ["OsvParser", "SheetResult", "data_rows"] LOCATOR_PREFIX = "sheet:" INN_COLUMN_KEY = "inn" NO_DATA_ROWS_WARNING = "нет строк данных" +DEFAULT_DATA_TYPE: DataType = "BU" +DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ" def _columns(header: TableHeader) -> dict[str, str]: @@ -181,7 +184,7 @@ class OsvParser: step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) step = "иерархия строк" - levels = resolve_levels(body, total_row, deadline) + levels = levels_with_indicators(body, total_row, deadline) step = "строки ИНН" glue = glue_inn_rows( body, @@ -194,13 +197,18 @@ class OsvParser: rules=rules, ) 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( glue.rows, glue.levels, schema.groups, header, sheet, - doc.data_type, + data_type, total_row, rules, inns=glue.inns, @@ -215,6 +223,7 @@ class OsvParser: notes = [ hierarchy_note(levels.strategy), *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_INN_ROWS, glue.warnings), ] diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index f12a0b6..9e7846c 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -317,6 +317,8 @@ class DataTypeRules(_Model): header: list[tuple[Pattern, DataType]] standalone_header: list[tuple[Pattern, DataType]] row: list[tuple[Pattern, RowDataType]] + indicator_column: list[tuple[Pattern, DataType]] + indicator_column_untyped: list[Pattern] currency_codes: list[str] currency_aliases: dict[str, list[Pattern]] diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index cb415b2..2a5f9d0 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -425,6 +425,67 @@ def form_sheet(name: str, *lines: str) -> SheetSpec: 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")] +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), "s31_rows_below_total.xlsx": (_s31, 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) @@ -860,7 +987,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s32 (xlsx, один xlsb и один xls) в указанный каталог.", + description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index c398702..a808005 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -69,6 +69,10 @@ EXPECTED_NAMES = [ VAL_KOL_VO_FILE, ROWS_BELOW_TOTAL_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_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -93,7 +97,7 @@ class Expect: status: ResultStatus = ResultStatus.DONE documents: int = 1 variant: int = 1 - data_type: str = "BU" + data_type: str | None = "BU" organization: str = ORG # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех variants: tuple[int, ...] = () @@ -144,6 +148,10 @@ EXPECTED: dict[str, Expect] = { ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), # итог подписан «Итого по счету 60»: точного «Итого» нет 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: - """В `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()) assert present == [name for name in EXPECTED_NAMES if name 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].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 diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index b0f335e..f90f4bd 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -26,6 +26,7 @@ def row( # noqa: PLR0913 - строка теста описывается по data_type: DataType | None = "BU", text: str = "строка", code: str | None = "60", + indicator: bool = False, ) -> ClassifiedRow: body = BodyRow( row=number, @@ -48,6 +49,7 @@ def row( # noqa: PLR0913 - строка теста описывается по counterparty_inn=None, data_type=data_type, extra=None, + indicator=indicator, ) @@ -198,8 +200,24 @@ def test_currency_and_quantity_children_are_excluded() -> None: rows = [ account(7, ROOT), 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(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"), + row( + 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), ] @@ -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: 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 @@ -376,7 +396,7 @@ def test_run_checks_warning_lists_are_capped() -> None: assert "100," not in identity -# --- Счета без сальдо по аналитике (OQ-132): 91, 99 --- +# --- Счета без сальдо по аналитике (OQ-132): 90, 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.total_row_matches_root is True 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 diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 1aaa77a..e120156 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -12,13 +12,14 @@ import pytest from gridbuilder import C, L, M, sheet_from_rows 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.detect import find_table_header from src.parser.errors import ParseTimeoutError from src.parser.grid import Sheet from src.parser.header import build_group_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.rules import Rules, load_rules @@ -1037,3 +1038,77 @@ def test_classified_rows_cover_every_body_row(rules: Rules) -> None: parsed = parse(_basic(), rules) 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", + } diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 9cfe023..87ec004 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -438,6 +438,36 @@ def test_header_without_text_column_left_of_sums_is_invalid_format(rules: Rules) # --- ИНН и 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: sheet = sheet_from_rows( [ diff --git a/api/tests/unit/parser/test_indicators.py b/api/tests/unit/parser/test_indicators.py new file mode 100644 index 0000000..e8b1412 --- /dev/null +++ b/api/tests/unit/parser/test_indicators.py @@ -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] diff --git a/api/tests/unit/parser/test_inn_rows_osv.py b/api/tests/unit/parser/test_inn_rows_osv.py index 3c44a80..6d90416 100644 --- a/api/tests/unit/parser/test_inn_rows_osv.py +++ b/api/tests/unit/parser/test_inn_rows_osv.py @@ -47,3 +47,41 @@ def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) - assert not ok expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте" 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 diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 765c291..e8f0f0f 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -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: - """R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком.""" - assert list(rules.headers.service_columns) == ["inn"] + """R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком. + Ключ `indicator` (колонка «Показатели», AURORA-1613) читает код — поэтому он в списке.""" + assert list(rules.headers.service_columns) == ["inn", "indicator"] @pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"]) @@ -621,8 +622,8 @@ def test_currency_code_unknown(rules: Rules, text: str) -> None: # --- Счета без сальдо по аналитике (osv_checks.yaml, OQ-132) --- -def test_accounts_without_analytic_balance_are_91_and_99(rules: Rules) -> None: - assert rules.checks.accounts_without_analytic_balance == ["91", "99"] +def test_accounts_without_analytic_balance_are_90_91_and_99(rules: Rules) -> None: + assert rules.checks.accounts_without_analytic_balance == ["90", "91", "99"] @pytest.mark.parametrize( @@ -633,8 +634,12 @@ def test_accounts_without_analytic_balance_are_91_and_99(rules: Rules) -> None: (" 91.09 ", True), ("99", True), ("99.01.1", True), - ("90", False), - ("90.01", False), + ("90", True), + ("90.01", True), + ("90.01.1", True), + (" 90.09 ", True), + ("900", False), + ("901", False), ("910", False), ("9", 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"): 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"]) diff --git a/docs/superpowers/plans/2026-10-19-osv-indicator-column.md b/docs/superpowers/plans/2026-10-19-osv-indicator-column.md new file mode 100644 index 0000000..9bc98ff --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-osv-indicator-column.md @@ -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/`. Команды 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 `. +- 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/*.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 — не трогать: статусы ведёт главная сессия. diff --git a/openspec/changes/osv-indicator-column/.openspec.yaml b/openspec/changes/osv-indicator-column/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/osv-indicator-column/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/osv-indicator-column/design.md b/openspec/changes/osv-indicator-column/design.md new file mode 100644 index 0000000..ed8033a --- /dev/null +++ b/openspec/changes/osv-indicator-column/design.md @@ -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. diff --git a/openspec/changes/osv-indicator-column/proposal.md b/openspec/changes/osv-indicator-column/proposal.md new file mode 100644 index 0000000..1068e54 --- /dev/null +++ b/openspec/changes/osv-indicator-column/proposal.md @@ -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` как пришёл. diff --git a/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md b/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md new file mode 100644 index 0000000..529813d --- /dev/null +++ b/openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md @@ -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=…` нет diff --git a/openspec/changes/osv-indicator-column/tasks.md b/openspec/changes/osv-indicator-column/tasks.md new file mode 100644 index 0000000..4273ff9 --- /dev/null +++ b/openspec/changes/osv-indicator-column/tasks.md @@ -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`