Срез 2a: контракт v1.1 (rows[].extra), §1 по факту парсера, план срезов 2a–2d, OQ-98…109 #10

Merged
aep merged 8 commits from feat/9-slice-2a-osv-parser into main 2026-10-05 19:20:12 +03:00
12 changed files with 472 additions and 44 deletions

View File

@ -7,7 +7,8 @@
## §1. Парсинг ОСВ (tabular-parser + маппинг в ядре)
Источник: «Парсинг ОСВ» (DOC-260010), FNR01 (DOC-037740), реальные файлы `fixtures/osv/`.
Источник: «Парсинг ОСВ» (DOC-260010), FNR01 (DOC-037740), реальные файлы `fixtures/osv/`. Реализация — `tabular-parser`,
Срез 2a (change `slice-2a-osv-parser`); что именно сделано и как выглядит результат — §1.6.
### 1.0 Главный принцип: структура определяется динамически, не по координатам
@ -15,28 +16,32 @@
бухгалтером. **Ни одна буква колонки, ни один номер строки, ни одно имя листа не являются контрактом.** Фикстуры в
`fixtures/osv/` — это примеры двух организаций, а не спецификация формата. Парсер обязан:
1. **Найти лист(ы) с ОСВ** по содержимому: любая ячейка, матчащая `оборотно[- ]сальдовая ведомость` (без учёта регистра,
«ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколько `document`.
1. **Найти лист(ы) с ОСВ** по содержимому: ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости всегда
над таблицей), матчащая `оборотно[- ]сальдовая ведомость` (без учёта регистра, «ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколько `document`.
Листы без признаков ОСВ/ББ/ОФР — пропускать с warning, не падать.
2. **Найти заголовок таблицы** по содержимому: строка(и), где встречаются одновременно «Сальдо на начало…»,
«Оборот(ы) за период», «Сальдо на конец…» (с вариантами: «Нач. сальдо», «Обороты», «Кон. сальдо», «Дебет/Кредит»,
«Дт/Кт», «Д-т/К-т»). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет»
«Дт/Кт», «Д-т/К-т»). **Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на
начало»** (иначе заголовок не найден → `INVALID_FORMAT`, §1.6). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет»
в первой колонке, так и строкой «Субконто», так и перечислением через запятую (вариант 2).
3. **Определить колонки сумм по тексту**, а не по позиции: для каждой из трёх групп («начало», «оборот», «конец»)
найти подзаголовки «Дебет»/«Кредит» в следующей строке под ней (с учётом merged-диапазонов). Допустимы ОСВ без
«начала» (только обороты и конец), с одной свёрнутой колонкой сальдо (знак = сторона), с дополнительными
колонками («Количество», «Валюта», «ИНН», «КПП», «Код»). Неизвестные колонки — сохранять в `extra` строки, не терять.
4. **Определить колонку текста строки** — первая непустая слева от сумм (обычно A, но не обязательно); ИНН — колонка с
заголовком `ИНН` **или** колонка, где ≥ 80 % непустых значений — 10/12 цифр.
заголовком `ИНН` **или** колонка, где ≥ 80 % непустых значений — ровно 10/12 цифр (число из 9/11 цифр при этом выборе
нулём не дополняется, иначе числовой КПП сошёл бы за ИНН — R-24).
5. **Определить иерархию строк** по outline level Excel; если уровней нет (файл пересохранён, csv, другая система) —
по отступу (ведущие пробелы / `alignment.indent`), по жирности счёта, по шаблону кода счёта и, в крайнем случае, по
факту «сумма детей = родитель». Стратегии применяются каскадом, выбранная стратегия пишется в `metrics.warnings`.
факту «сумма детей = родитель». Стратегии применяются каскадом, выбранная стратегия пишется в `metrics.warnings` как
`sheet=<имя листа> hierarchy=<outline|indent|account_pattern|sums|trivial>`. Строка «Итого» в каскаде не участвует
(всегда уровень 0); при меньше чем двух строках данных (пустая ОСВ) — `trivial`.
6. **Шапку документа** (организация, счёт, период, «выводимые данные») искать регулярками по всем ячейкам выше заголовка
таблицы; отсутствие любого поля — не ошибка, а `null` + warning (ядро попросит аналитика уточнить при проверке).
7. **Самопроверка результата** (контрольные суммы §1.4) — единственный способ понять, что структура угадана верно.
Если тождества не сходятся — не «подгонять», а вернуть `needs_check` с диагностикой, какой шаг не уверен.
8. Правила парсера — **данные, а не код**: словари синонимов заголовков, группировок, периодов лежат в
`tabular-parser/rules/*.yaml` и расширяются без релиза. Новый формат ОСВ = новая фикстура + при необходимости новый
`tabular-parser/api/rules/*.yaml` (`osv_headers`, `osv_groups`, `periods`, `data_types`) и расширяются без релиза. Новый формат ОСВ = новая фикстура + при необходимости новый
синоним, а не новая ветка `if`.
Тесты парсера строятся по принципу «свойств»: для любого входного файла результат удовлетворяет инвариантам §1.4,
@ -84,24 +89,40 @@ r11 Договоры ←
### 1.2 Колонки сумм
Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту
(§1.0 п. 3). Всё правее последней распознанной колонки сумм — не часть таблицы (в фикстурах там встречаются посторонние
числа) — складывать в `extra`, в суммы не брать. Числа могут быть numeric **или** текстом («1 234 567,89», «(1 234)»,
(§1.0 п. 3). Всё правее последней колонки «Кредит» — не часть таблицы (в фикстурах там встречаются посторонние
числа): игнорируется, в суммы и в `extra` не попадает. В `rows[].extra` (контракт v1.1) идут только колонки **внутри**
таблицы, не сопоставленные с шестью суммами и ИНН (ключ — заголовок колонки: «КПП», «Количество»…). Числа могут быть numeric **или** текстом («1 234 567,89», «(1 234)»,
«-», «—», неразрывные пробелы) — нормализовать в Decimal; пустая ячейка/прочерк = NULL (не 0). Отрицательные возможны.
### 1.3 Строки
Для каждой строки табличной части: `row_number` (исходный номер), `level` (= outline level Excel; в xls — `rowinfo_map`),
`row_value` (текст первой колонки), шесть сумм, ИНН (если колонка есть; оставить только цифры, иначе NULL),
Для каждой строки табличной части: `row_number` (исходный номер), `level` (outline level Excel, в xls — `rowinfo_map`; без группировок — каскад §1.0 п. 5),
`row_value` (текст первой колонки), шесть сумм, ИНН (**только из колонки ИНН**; в ячейке оставляются цифры, результат
только при 10 или 12 цифрах — «ИНН 352819549855» → `352819549855`, иначе NULL; число из 9/11 цифр — ИНН региона 01–09
с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН внутри текста строки контрагента
(«Полиграф, 3528315710») парсер не извлекает: `counterparty_name` = весь текст строки, **[открыто]** OQ-100),
`data_type` (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки).
Строки «Вал.», «Кол.» и трёхбуквенный код валюты («USD») — не уровень группировки, а вид сумм родителя:
`group_type = OTHER`, `data_type = CURRENCY | QUANTITY`, позицию в `group_schema` не занимают, **не наследуют**
`counterparty_name`/`counterparty_inn` (контрагента потребитель находит по родителю), `account_code_raw` наследуют от
ветки; их суммы остаются в шести полях, но в проверки родителя не входят (§1.4).
Классификация строки (п. 6–10 «Парсинг ОСВ»):
1. Строка — счёт, если текст матчится `^\d{2,3}(\.\d{1,2}){0,2}(,\s.*)?$` (с опциональным названием после запятой:
«60.01, Расчеты с поставщиками») → ACCOUNT, `account_code` (нормализованный), все дочерние строки наследуют счёт.
1. Строка — счёт, если текст матчится `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` (флаги `re.ASCII | re.DOTALL`:
цифры только 0–9, название после запятой может быть многострочным; регистр учитывается;
сегмент субсчёта — 1–2 цифры **или 1–3 заглавные буквы**: буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ» встречаются в
8 реальных файлах; после запятой — опциональное название, пробел необязателен: «60.01, Расчеты с поставщиками»)
→ ACCOUNT, `account_code_raw` — код как в файле без названия, `account_code` (нормализованный) строит ядро, все
дочерние строки наследуют счёт. Остаточный риск: имя контрагента вида «12.ООО» будет принято за счёт. Счета с буквой в
**первом** сегменте (забалансовые «МЦ.04», «ГТД», «НЕ.01.1») не распознаются как ACCOUNT — **[открыто]** OQ-103.
2. Не-счёт: позиция в ветке = количество не-счётных предков + 1 → соответствующая группировка из `group_schema`
(после последнего ACCOUNT). Увеличение уровня — ребёнок, равный — сосед, уменьшение — конец группы.
3. После COUNTERPARTY дочерние продолжают классифицироваться по схеме (CONTRACT, затем OTHER).
4. Строка «Итого» (level 0, последняя) — контрольная сумма всего документа; не сохранять как `osv_entry`
**[открыто №2]**, но использовать для сверки.
**[открыто №2]**, но использовать для сверки. Парсер отдаёт её в `rows` как `group_type = TOTAL` (level 0, без
родителя; `ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро — при маппинге строку TOTAL отбросить); в
`metrics.rows` она не считается.
5. Все остальные строки сохраняются (п. 11 «Парсинг ОСВ») с `parent_entry_id` **[открыто №2]**.
Нормализация кода счёта: `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1`; `account_root` = первые две-три цифры
@ -109,11 +130,35 @@ r11 Договоры ←
### 1.4 Контрольные суммы парсера
Допуск ±0.01, пустое значение = 0.
- По каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit == closing_debit − closing_credit` (±0.01).
- По каждому родителю: суммы родителя = Σ детей того же `data_type` (по каждой из шести колонок). Строки CURRENCY/QUANTITY
в суммирование не входят.
- Строка счёта верхнего уровня == «Итого».
Нарушение → `recognition_job.status = needs_check`, `document.status = NEEDS_CHECK`, причина — в `metrics.warnings`.
- По каждому родителю: суммы родителя = Σ детей того же `data_type`. Строки CURRENCY/QUANTITY в суммирование не входят
и сами родителями не проверяются. **Обороты** сверяются по колонкам (дебет с дебетом, кредит с кредитом). **Сальдо**
(начало, конец) — по колонкам либо, **если у родителя сальдо свёрнуто** (не больше одной ненулевой стороны), по нетто
`дебет − кредит` (R-21): 1С показывает счёт свёрнутым сальдо (62 = 16,1 млн Дт), а субсчета и контрагентов —
развёрнутым (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт). Без этого правила 32 из 66 реальных файлов не сходились бы
по шести колонкам только из-за способа показа. **[открыто]** OQ-105: подтвердить, что нетто-свёртка родителя —
штатное поведение 1С для всех версий выгрузки. Остаточные риски (проверками §1.4 не ловятся):
- RR-1 — у свёрнутого родителя сверяется только нетто: распределение сальдо детей между Дт и Кт (перестановка сторон
при совпавшем нетто) не проверяется;
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя с потерянной в файле стороной
неотличимо от нетто-показа 1С и проходит при совпавшем нетто;
- RR-3 — нетто допускается на любом уровне дерева (не только «счёт → субсчета»): ошибка распределения сальдо между
договорами под свёрнутым контрагентом не ловится;
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с развёрнутым родителем по колонкам;
- слепая зона: обе стороны сальдо развёрнутого ребёнка увеличены на одно X, а его непосредственный родитель свёрнут —
не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто, нетто ребёнка не изменилось,
значения родителя и выше не затронуты);
- родитель (0, 0) при детях (X Дт, X Кт) принимается (нетто 0) — осознанное поведение, закреплено тестом;
- вне R-21: текст вида «12.ООО» принимается за счёт (§1.3, R-16).
- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (**пустая ОСВ**: заголовок и
сразу «Итого» — в фикстурах две, schastl 58 и 66 за 2 кв. 2025) — «Итого» должна быть пустой или нулевой; такая ОСВ
штатна: `done`, `hierarchy=trivial`, `metrics.rows = 0`, предупреждение «нет строк данных». Если строка «Итого» не
найдена вовсе — сверка невозможна: `total_row_matches_root = false` и предупреждение «строка «Итого» не найдена»
(результат `needs_check`, R-20).
Нарушение → `recognition_job.status = needs_check`, `document.status = NEEDS_CHECK`, причина — в `metrics.warnings`,
номера строк — в `checks` результата. Парсер структуру под сходимость не подгоняет.
### 1.5 Маппинг периода (FNR01) → `period_quarter`, `is_cumulative`
@ -127,12 +172,53 @@ r11 Договоры ←
| 4 квартал, IV кв. | Q4 | **false** [открыто] |
| год, за 2025 год, 12 месяцев, годовая | Q4 | true |
`ASSUMPTION(OQ-02)`: парсер определяет `period_quarter` и `is_cumulative` **только по тексту периода**
(`rules/periods.yaml`, правила идут от специфичных к общим): квартальная ведомость Q2–Q4 считается не нарастающей;
сверку по данным (`Q3.opening == Q1.opening`) выполняет ядро и может переопределить признак. `period_year` — четыре
цифры года 19xx/20xx из текста, не являющиеся частью длинного числа.
Практика на фикстурах: ОСВ «за 3 квартал 2024» у обеих компаний — **квартальные** (opening ≠ сальдо на 01.01), а
ОСВ «за 2024 г.» — годовые. Признак можно проверить по данным: если есть ОСВ Q1 того же года и
`Q3.opening == Q1.opening` → нарастающая.
Сверка периода документа с периодом версии → `document.period_check` (MATCH / MISMATCH / UNKNOWN); загрузка не блокируется.
### 1.6 Результат парсера (Срез 2a): статусы, коды, лимиты, словари
Результат — файл по контракту v1.1: по одному `osvDocument` на лист ОСВ (`locator = sheet:<имя листа>`; имя листа
подставляется в `locator` и сообщения, но поиск по нему не ведётся). Форма и пример — `contracts/examples/
result-osv-needs-check.json`.
| Исход | Условие |
|---|---|
| `done` | контрольные суммы §1.4 сошлись на всех листах |
| `needs_check` | нарушена любая проверка §1.4 хотя бы на одном листе (или «Итого» не найдена); причины — `metrics.warnings`, номера строк — `checks` |
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ; в сообщении — число просмотренных листов |
| `error` / `INVALID_FORMAT` | лист ОСВ есть, но нет заголовка таблицы, обязательной группы сумм (§1.0 п. 2) или иерархию определить не удалось; в сообщении — лист и шаг |
| `error` / `UNREADABLE` | книга не открывается (лог WARNING) |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура файла не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек) |
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение; одна строка `Тип: сообщение`, стек — в лог ERROR |
Формат определяется по сигнатуре байтов (zip → XLSX, либо XLSB при наличии `xl/workbook.bin`; OLE2 → XLS), а не по
`file_type` запроса; несовпадение — предупреждение. Листы форм ББ/ОФР (§12 п. 1) и листы без признаков ОСВ пропускаются с
предупреждением (`sheet=<имя> skipped: statement (slice 2b)` / `skipped: нет признаков ОСВ`). Другие предупреждения:
`sheet=<имя> hierarchy=<стратегия>` (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное
поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение
«xlsb: outline level недоступен»).
Лимиты: `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) и `PARSE_TIMEOUT_SECONDS` (120 с; кооперативный — проверка между листами,
в циклах по листу и между стратегиями; чтение книги библиотекой он не прерывает). Поэтому до библиотеки чтения действуют
лимиты чтения (R-23, R-25) → `UNSUPPORTED_FORMAT`: распакованный zip (xlsx/xlsb) ≤ 10 × `PARSE_MAX_BYTES`, суммарная
площадь объединённых ячеек книги ≤ 200 000 (реальный максимум 10 090; xlsx — потоковый expat-скан всех XML-частей до
openpyxl, после чтения — только диапазоны, которые индекс листа раскладывает по ячейкам). Объём самих ячеек,
`sharedStrings`/стили и XML-бомбы (`defusedxml`) не ограничены — **[открыто]** OQ-108. Любой реальный файл разбирается быстрее 5 с (максимум ≈ 0,6 с).
Словари: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет»/«Кредит», колонка ИНН, «Итого», метки шапки),
`osv_groups.yaml` (группировка → `group_type`), `periods.yaml` (период → квартал и нарастающий итог), `data_types.yaml`
(БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют). Каждая запись словаря обязана срабатывать хотя бы на одной
фикстуре или синтетике (тест покрытия).
---
## §2. Приведение периодов (DOC-259006)

View File

@ -93,14 +93,25 @@
расхождение 0,5 млн: исправительные проводки задним числом, тоже реальный кейс). «За 2024 г.» — годовые.
Проверять по данным, не только по тексту.
9. **Валюта**: `Валюта БУ, НУ: RUB` или `руб.` — равнозначно. Строк Вал./Кол. в этом наборе нет, но ТЗ их описывает
(ОСВ 60.32 в у. е.) — нужен синтетический тест.
(ОСВ 60.32 в у. е.) — закрыто синтетикой `s04` (§7).
10. **Названия субсчетов** в строках счёта могут присутствовать («60.01, Расчеты с поставщиками») или нет — в этом
наборе нет; поддержать оба.
наборе нет; поддержаны оба, синтетика `s09` (§7).
11. **Контрагенты-физлица** и ИП («Абрамова Татьяна Викторовна (Перевозчик)», «Аверина Е.И. ИП», «Ведомость в счет
з/платы») — не ЮЛ; резолв даёт NULL, тип «Внешний». Суффиксы в скобках — часть имени.
12. **xls (BIFF8)** читается `xlrd` (`formatting_info=True` для outline level через `rowinfo_map`); **xlsx** — `openpyxl`
(не `read_only`, иначе нет `row_dimensions[].outline_level`). XLSB — `pyxlsb` (в наборе нет; ТЗ требует).
(не `read_only`, иначе нет `row_dimensions[].outline_level`). XLSB — `pyxlsb` (в реальном наборе нет; ТЗ требует;
есть только рукописный синтетический `s13`, см. §7).
13. Размеры: до ~1000 строк, до 330 КБ; 2280 merged-диапазонов в файле на 677 строк — не падать на merged.
14. **Две пустые ОСВ**: `schastl/2025-Q2/…по счету 58 за 2 квартал 2025 г..xlsx` и `…по счету 66 за 2 квартал 2025 г..xlsx` —
заголовок и сразу «Итого», строк данных нет. Штатный случай: `done`, `hierarchy=trivial`, `metrics.rows = 0`.
15. **Буквенные субсчета 1С** «76.АВ», «76.ВА», «76.ОТ» — в 8 файлах ОСВ 76 (АО РБ: 2024 г., 3 кв. 2024, 1–3 кв. 2025;
Счастливочная: 1–3 кв. 2024). Это счета (ACCOUNT), а не контрагенты; регулярка кода счёта допускает 1–3 заглавные буквы
в сегменте субсчёта (§1.3 п. 1 правил).
16. **Сальдо родителя нетто, детей развёрнуто**: 1С показывает счёт свёрнутым сальдо (62 = 16,1 млн Дт), а субсчета —
развёрнутым (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт); без учёта этого 32 из 66 файлов не сходились бы по
колонкам (§1.4 правил, R-21).
17. **ИНН внутри текста строки**: у Счастливочной в ОСВ 76 за 1–3 кв. 2024 встречаются контрагенты вида «Полиграф, 3528315710»
(20 строк) без колонки ИНН; парсер ИНН из текста не извлекает (OQ-100).
## 4. ББ / ОФР (PDF) и ответы ИС Сканирование
@ -140,7 +151,7 @@ fixtures/
osv/
rb/ 2024-Q1/ 2024-Q2/ 2024-Q3/ 2024-FY/ 2025-Q1/ 2025-Q2/ 2025-Q3/ # файлы с исходными именами
schastl/ ...
synthetic/ header-variant-2.xlsx, currency-rows.xlsx, xlsb-sample.xlsb, broken-header.xlsx
synthetic/ README.md + s01…s16 (генератор в tabular-parser, §7)
statements/
rb/ schastl/ gir-bo/ # pdf
statements-xlsx/ # ББ/ОФР в Excel (§12): лист «Отчетность» из Анализ*.xlsx,
@ -154,7 +165,7 @@ fixtures/
# буквально)
golden/
Консолидация_Группа РБ.xlsx # эталон КФО по датам
expected_osv_parse/*.json # ожидаемый результат парсера (зафиксировать после ревью)
expected_osv_parse/<зеркало osv/ без расширения>.json # golden-снимки парсера ОСВ: 82 файла (66 реальных + 16 синтетических)
directories/
ecp-export-russkiy-biskvit.xlsx # срез выгрузки ЕЦП CRM по ГК «Русский Бисквит» (Срез 1)
```
@ -186,5 +197,49 @@ fixtures/
примеры РФ). Загрузчик — `python -m scripts.seed [--dev]` из `arm-core/api/` (идемпотентен, upsert по натуральному ключу;
в compose его выполняет одноразовый сервис `arm-core-migrate`). Здесь остаются только тестовые данные группы.
Полный план счетов 1С из бандла макета не передан — seed строится на Плане счетов 94н и типовых субсчетах 1С (OQ-47).
Первый прогон парсера на `osv/**` сохранить как `expected_osv_parse/*.json` после ручной сверки итогов со строкой «Итого»
— дальше это regression-набор.
Первый прогон парсера на `osv/**` сохранён как `expected_osv_parse/*.json` после ручной сверки итогов со строкой «Итого»
(§7) — дальше это regression-набор.
## 7. Синтетика, golden-снимки и xlsb парсера ОСВ (Срез 2a)
Реальные файлы покрывают один вариант шапки и один формат файла (xls/xlsx из 1С 8.3). Остальное — синтетика
`fixtures/osv/synthetic/` (вымышленные организации и ИНН; суммы согласованы арифметически, поэтому все файлы, кроме
`s10`, дают `done`). Файлы собирает детерминированный генератор `tabular-parser/api/tests/synthetic/generate.py`
(повторный запуск даёт те же байты), ожидания по каждому файлу — в `tests/synthetic/test_synthetic_files.py`, описание —
в `fixtures/osv/synthetic/README.md`.
| Файл | Назначение (что из §1 проверяет) |
|---|---|
| `s01_variant2_header` | шапка варианта 2 («Период:», «Детализация по субсчетам, субконто: …», заголовок «Субконто», данные «Сумма») |
| `s02_no_outline_indent` | иерархия без outline level — по отступам (`hierarchy=indent`); `Валюта БУ, НУ: руб.` |
| `s03_no_outline_no_indent` | ни outline, ни отступов — по кодам счетов и жирности (`hierarchy=account_pattern`); римский квартал |
| `s04_currency_quantity_rows` | строки «USD», «Вал.», «Кол.» под контрагентом (`CURRENCY`/`QUANTITY`) |
| `s05_text_numbers` | суммы текстом: «1 234 567,89», «(1 234,00)», «—», неразрывные и узкие пробелы |
| `s06_two_osv_and_other_sheet` | два листа ОСВ и посторонний лист «Справка» |
| `s07_no_opening_collapsed_closing` | нет группы «Сальдо на начало»; свёрнутое сальдо на конец одной колонкой (в т. ч. R-21) |
| `s08_extra_kpp_column` | колонка «КПП» между «ИНН» и суммами → `extra` |
| `s09_subaccount_names` | названия субсчетов в строке счёта, буквенный субсчёт |
| `s10_broken_sum` | испорченная сумма → `needs_check` с номерами строк |
| `s11_header_repeat` | заголовок таблицы повторяется внутри данных (печать по страницам) |
| `s12_statement_and_osv` | лист ББ и лист ОСВ в одной книге: форма пропускается с предупреждением |
| `s13_no_outline_indent` (`.xlsb`) | та же ведомость, что `s02`, в xlsb |
| `s14_statement_forms` | три формы ББ/ОФР, каждая опознаётся своим признаком |
| `s15_group_title_synonyms` | названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо» |
| `s16_header_synonyms` | шапка без организации, период «2024», группировки `OTHER`, «Детализация: …» |
`s14`–`s16` добавлены тестом покрытия словарей: каждая запись `rules/*.yaml` обязана сработать на реальных или
синтетических файлах.
**xlsb рукописный.** `s13` собран **не Excel и не LibreOffice** (их нет на машине разработки), а минимальным писателем
BIFF12 `tabular-parser/api/tests/synthetic/xlsb_writer.py`: он пишет только то, что читает `pyxlsb`. Файл читается
парсером, но совместимость с xlsb, сохранённым Excel (стили, другая раскладка записей), **не проверена**; нужен реальный
файл из Excel (OQ-102). Инструкция замены — в README `fixtures/osv/synthetic/`.
**Golden-снимки.** Для каждого файла `fixtures/osv/**` (включая синтетику) в `fixtures/golden/expected_osv_parse/` лежит
результат разбора (82 файла = 66 реальных + 16 синтетических): файл результата контракта v1.1 без `job_id`, `file_id`,
`service.version`, `metrics.duration_ms`. Снимок — **регрессия, не оракул**: он фиксирует, что парсер выдаёт сейчас.
Обновление — только `make golden-update` в `tabular-parser` с просмотром diff и отдельным коммитом в `fixtures`;
`make golden-check` (код возврата 1 при расхождении) — без записи. Оракулы корректности — property-тест
(`done`, контрольные суммы, корень = счёт шапки, период распознан, разбор < 5 с) и ручная сверка «Итого» с ячейками
файла, прочитанными независимо от парсера (АО РБ 60 за 1 кв. 2025, Счастливочная 62 за 3 кв. 2024 xls, ОСВ по субсчёту
58.03).

View File

@ -7,7 +7,7 @@
| ID | Вопрос | Откуда | Влияет на | Статус / допущение |
|---|---|---|---|---|
| OQ-01 | Хранить ли все строки ОСВ или только контрагентские; нужен ли `parent_entry_id` (замечание 2 ревью v5.13) | DBML, «Парсинг ОСВ» п. 11 | срез 2 (парсер, `osv_entry`) | **закрыт решением владельца 05.10.2026:** хранить **все** строки ОСВ с `level` и `parent_entry_id`; строку «Итого» не хранить, использовать только для сверки (парсер отдаёт её как `group_type = TOTAL`, маппер Среза 2c отбрасывает) |
| OQ-02 | ОСВ «за 2/3/4 квартал» — квартальная или нарастающая? Как определять автоматически | FNR01 маппинг периода | срез 2, §2 правил | **закрыт решением владельца 05.10.2026:** ОСВ «за N квартал» — **квартальная**; признак сверяется по данным: если есть ОСВ Q1 того же года и `opening` совпадает — нарастающая (парсер ставит `is_cumulative` по тексту, ядро переопределяет при маппинге/в консолидации) |
| OQ-02 | ОСВ «за 2/3/4 квартал» — квартальная или нарастающая? Как определять автоматически | FNR01 маппинг периода | срез 2, §2 правил | **закрыт решением владельца 05.10.2026:** ОСВ «за N квартал» — **квартальная**; признак сверяется по данным: если есть ОСВ Q1 того же года и `opening` совпадает — нарастающая (парсер ставит `is_cumulative` по тексту, ядро переопределяет при маппинге/в консолидации); **допущение парсера (Срез 2a):** `period_quarter` и `is_cumulative` вычисляются только по тексту периода (`rules/periods.yaml`; Q2–Q4 «N квартал» → `false`), сверка по данным — в ядре, подсрез 2c (`ASSUMPTION(OQ-02)`) |
| OQ-03 | Приведение квартальных ОСВ к нарастающим: ключ соединения (контрагент vs договор), пропуск промежуточного квартала | DOC-259006 | срез 4 | допущение: ключ (счёт, контрагент); пропуск квартала → блокирующая ошибка чек-листа |
| OQ-04 | ОСВ по субсчёту (58.03 вместо 58) закрывает ли обязательный документ «ОСВ 58» | фикстуры | чек-лист | **закрыт решением владельца 05.10.2026:** **нет**, ОСВ по субсчёту (58.03) — только субсчёт, обязательный документ «ОСВ 58» закрывает только полная ОСВ по счёту 58; чек-лист считает документ выполненным при `osv_account_code = код правила` |
| OQ-05 | Нужен ли статус `document.RECOGNIZED` или все идут через `NEEDS_CHECK → CONFIRMED` | DBML | срез 2–3 | **закрыт решением владельца 05.10.2026:** статус `RECOGNIZED` **нужен**: документ после успешного распознавания — `RECOGNIZED`, при нарушениях — `NEEDS_CHECK`, после подтверждения аналитиком — `CONFIRMED` |
@ -86,7 +86,7 @@
| OQ-78 | Шлюз читает ровно `<output>/<job_id>/<job_id>.json` (спека); старый код читал все файлы каталога `Output/<id>/`. Какие файлы и когда реальная ИС кладёт в `Output`, в каком порядке (момент готовности)? | scan-gateway: п. 5 таблицы предложений (T8.5) | срез 3 (стенд ИС) | **закрыт человеком 04.10.2026:** ИС кладёт **все распознанные документы одновременно** в каталог `<job_id>`, по одному json на документ (пример: `aep_3.json` + `aep_3_ofr.json`); шлюз читает **все** json каталога и склеивает в один ответ (готовность — по стабилизации набора файлов, `SCAN_SETTLE_SECONDS`); ошибка одного документа не отменяет остальные (`metrics.warnings`). Реализуется в Срезе 0 (доработка PR scan-gateway #3) |
| OQ-79 | Временный файл `.<имя>.part` лежит в `Input` (атомарная запись `.part` + rename): реальная ИС без маски может забрать недописанный файл. Уточнить у владельцев ИС маску и поведение или перенести временный файл в служебный подкаталог рядом с `Input`? | scan-gateway: п. 6 таблицы предложений (R-5, T7/T8) | срез 3 (стенд ИС) | **закрыт человеком 04.10.2026:** временный файл записи уходит из `Input` в служебный каталог рядом (`SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же шаре — `rename` остаётся атомарным). Реализуется в Срезе 0 (доработка PR scan-gateway #3) |
| OQ-80 | `make up` расходится с эталоном (scan-gateway и `dev/Makefile.common`): в `tabular-parser` нет `minio-init` — на свежем томе бакета `arm-ka` нет, `test_s3_integration` даёт `NoSuchBucket`, хотя README сервиса обещает бакет; в `arm-core` `up -d` без `--wait` и без `--project-name` (имя в compose фиксировано, безвредно). Также `check-full` в arm-core и tabular-parser — через пререквизиты (порядок при `make -j` не гарантирован), в scan-gateway — через `$(MAKE)`; `lint` arm-core включает `mypy scripts`. Подключить `include ../docs/dev/Makefile.common` в сервисах? | tabular-parser: `Makefile` (цель `up`); scan-gateway: п. 18 таблицы предложений (I-1); docs: O-1 и O-2 (задача 1 фазы 2), O-4 и O-5 (задача 2 фазы 2), `dev/checks.md`, `dev/Makefile.common` | срез 0 (процесс), все сервисы | допущение принято: эталон — scan-gateway, расхождения описаны в `dev/checks.md`, Makefile сервисов не правились (design #22, #24); выровнять — правкой Makefile сервисов (follow-up) |
| OQ-81 | Переменные `ARM_KA_DOCS` / `ARM_KA_FIXTURES` поддержаны неравномерно: `arm-core` — обе; `tabular-parser` — только `ARM_KA_DOCS`; `scan-gateway` — ни одной (жёстко `../docs`, `../../fixtures/ocr`). Текст причины skip («fixtures недоступен: положите репозиторий…») не содержит строки «ARM_KA_FIXTURES недоступен» из сценария «Нет фикстур» spec `quality-checks`; для docs-теста текст тоже без `ARM_KA_DOCS`. Нарушение spec — править сервисы? | scan-gateway и tabular-parser (тесты, Makefile); docs: O-3 (задача 1 фазы 2), `dev/checks.md`; spec `quality-checks` | срез 0 (процесс), все сервисы | допущение принято: расхождение описано в `dev/checks.md`; правка тестов и Makefile в scan-gateway и tabular-parser — follow-up человеку (сервисы не правились) |
| OQ-81 | Переменные `ARM_KA_DOCS` / `ARM_KA_FIXTURES` поддержаны неравномерно: `arm-core` — обе; `tabular-parser` — только `ARM_KA_DOCS`; `scan-gateway` — ни одной (жёстко `../docs`, `../../fixtures/ocr`). Текст причины skip («fixtures недоступен: положите репозиторий…») не содержит строки «ARM_KA_FIXTURES недоступен» из сценария «Нет фикстур» spec `quality-checks`; для docs-теста текст тоже без `ARM_KA_DOCS`. Нарушение spec — править сервисы? | scan-gateway и tabular-parser (тесты, Makefile); docs: O-3 (задача 1 фазы 2), `dev/checks.md`; spec `quality-checks` | срез 0 (процесс), все сервисы | допущение принято: расхождение описано в `dev/checks.md`; правка тестов и Makefile в scan-gateway и tabular-parser — follow-up человеку (сервисы не правились); **частично закрыт (Срез 2a, tabular-parser):** хелпер `tests/fixtures_helpers.py` поддерживает `ARM_KA_FIXTURES` (текст skip называет переменную и путь), `ARM_KA_DOCS` поддержан в тесте идентичности контракта; `scan-gateway` — по-прежнему ни одной переменной, остаётся follow-up человеку |
| OQ-82 | `.dockerignore` в `arm-core` не исключает `api/.env.test`: файл попадает в образ (у `tabular-parser` и `scan-gateway` исключён). Исправлять? | arm-core `.dockerignore`; docs: T3.2 (задача 3 фазы 2) | срез 0 (образы), прод-сборка | допущение принято: безвредно (в файле стандартные dev-учётные данные compose, не секреты), в Срезе 0 не правилось; добавить исключение в arm-core — follow-up |
| OQ-83 | Контейнеры профиля `services` подписаны на те же subject и queue group общего NATS, что и экземпляры в интеграционных тестах сервисов (`recognition.result` / `arm-core`, `*.request`): NATS может отдать сообщение теста контейнеру и наоборот; интеграционные тесты `scan-gateway` и `arm-core` одновременно тоже делят NATS. Сделать queue group и subject-префикс настраиваемыми для тестов (например `NATS_TEST_PREFIX`) или поднимать отдельный NATS на тесты? | scan-gateway: п. 11 таблицы предложений (T14.1, T11.8); docs: O-11 (задача 3 фазы 2; подтверждено ревью для tabular-parser и scan-gateway), `dev/README.md` | срез 0 (процесс), интеграционные тесты | допущение принято: в `dev/README.md` предупреждение и команда `stop` для контейнеров профиля; не гонять `make test-integration` двух сервисов одновременно |
| OQ-84 | Запуск сервисов из venv без Nexus (`make venv` + `python run_server.py` из `api/` с `.env` из `docs/dev/.env.example`, healthcheck по `localhost`) в Срезе 0 не проверялся ни в одном из трёх сервисов. Известно по чтению конфигов (design #44): `.env.example` в нынешнем виде отвергнут `scan-gateway` (`OPENBAO__DATABASE__*`) и `tabular-parser` (`OPENBAO__DATABASE__*`, `OPENBAO__SMB__*` — `extra_forbidden`), в нём нет `OPENBAO__MINIO__MINIO_SECURE=false` (по умолчанию `true` — https к http-MinIO). Прогнать сценарий, зафиксировать набор переменных и портов (`SERVER__PORT`, `SCAN_HOTFOLDER_PATH` для `scan-gateway`), решить, нужен ли отдельный `.env.example` на сервис, и снять пометку «черновик» с раздела `dev/README.md`? | spec `dev-environment`, сценарий «Запуск из venv против compose без Nexus»; docs: design #43, `dev/README.md` («Запуск сервисов из venv без Nexus»); финальное ревью фазы 2: I-1 | срез 0 (процесс, окружение разработки); кандидат в follow-up issue | вопрос: допущение Среза 0 — раздел README остаётся черновиком, сценарий не подтверждён (проверка требовала `.venv` внутри сервисных репозиториев, в фазе 2 доступных только для чтения) |
@ -103,6 +103,18 @@
| OQ-95 | Код `LE_IN_OTHER_GROUP` в отчёте импорта называет ЮЛ (ИНН, имя) из ГК, которую филиальный пользователь с `DIRECTORY_ADMIN` видеть не должен. Допустимо ли раскрытие в отчёте импорта (право по seed только у ADMIN ГО)? | §13.3; §10.1; arm-core T10.9 | срез 1 | допущение: отчёт импорта вне policy видимости, право даётся только ГО |
| OQ-96 | Граница «`effective_date` не раньше сегодня» (OQ-21) считается по UTC: для МСК правило сдвигается на 3 часа после полуночи. Какой часовой пояс — «банковский» МСК или UTC? | §13.4; arm-core R-9 | срез 1 | **закрыт решением владельца 05.10.2026: граница «сегодня» — по МСК.** Код arm-core пока считает по UTC; перевод на МСК — follow-up RSHB/arm-core#8 |
| OQ-97 | Право `DIRECTORY_DELETE` по матрице §10 только у ADMIN: удалять алиасы и пустые ГСК/ГСЗ могут только администраторы, а не КА. Расширить право на KA_RF/KA_GO/CURATOR_GO? | §10; arm-core O-6 | срез 1 (seed) | **закрыт решением владельца 05.10.2026: право `DIRECTORY_DELETE` на КА не расширяется**, удаляют алиасы и пустые ГСК/ГСЗ только ADMIN; матрица §10 без изменений |
| OQ-98 | Единый список кодов валют для строк `data_type = CURRENCY` («USD», «EUR» под контрагентом): достаточно ли ISO 4217-списка из `rules/data_types.yaml` (`currency_codes`: RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED)? Нужны ли нестандартные написания и коды, которые используют конфигурации 1С | tabular-parser: design D-4, R-6, R-22 | срез 2c (маппинг CURRENCY-строк), словарь `data_types.yaml` | допущение принято: ISO-перечень, дополняется строкой в YAML вместе с синтетикой; коды не входят в тест покрытия словарей (R-22) |
| OQ-99 | Несколько ОСВ **одного счёта** в одной книге (разные листы: два периода или две организации): парсер выдаёт по одному `document` на лист (`locator = sheet:<имя>`) и ничего не дедуплицирует. Как ядро выбирает/сопоставляет такие документы с периодом и организацией? | tabular-parser: spec `osv-parsing` («Листы ОСВ находятся по содержимому») | срез 2c (маппинг, `document.period_check`) | допущение принято: каждый лист — отдельный документ, выбор — за ядром и аналитиком при проверке |
| OQ-100 | ИНН внутри **текста** строки контрагента («Полиграф, 3528315710», 20 строк в ОСВ 76 Счастливочной) и склеенная ячейка «ИНН/КПП»: извлекать ли ИНН в парсере или в ядре при резолве контрагента? Сейчас `counterparty_inn` берётся только из колонки ИНН (§1.3), `counterparty_name` = весь текст строки | tabular-parser: R-15, R-17 (design №25, №27) | срез 2c (резолв `counterparty_id` по ИНН/имени) | допущение принято: парсер не извлекает; ядро разбирает имя; при отрицательном решении — правило в парсере одной функцией |
| OQ-101 | Числа с ведущим «+», «.5», «5.» парсер отвергает как нечисловой текст (`null` + предупреждение с номером строки). Встречаются ли такие записи в выгрузках других систем; принимать ли их | tabular-parser: T7.3 | срез 2a+, словарь/правила `numbers.py` | допущение принято: отвергаются; в реальных файлах не встречаются |
| OQ-102 | Нужен **реальный xlsb из Excel**: единственный `fixtures/osv/synthetic/s13_no_outline_indent.xlsb` собран тестовым писателем BIFF12 и не доказывает чтение файлов, сохранённых Excel (стили, другая раскладка записей). Получить xlsb у аналитиков или пересохранить `s02` в Excel и заменить `s13` по инструкции в README fixtures | tabular-parser: R-9, O-7 | срез 2a (достоверность xlsb) | допущение принято: читаем `pyxlsb` без outline level и форматирования; проверка — на первом настоящем xlsb |
| OQ-103 | Счета с буквой в **первом** сегменте кода (забалансовые «МЦ.04», «ГТД», «НЕ.01.1») не распознаются как ACCOUNT: регулярка требует цифр в первом сегменте, строка станет контрагентом/группировкой по позиции. Нужны ли такие ОСВ, как их кодировать в `chart_of_account` | tabular-parser: R-16 (design №26), `classify.py` | срез 2c (план счетов), парсер | допущение принято: не распознаются; в 66 реальных файлах не встречаются |
| OQ-104 | Группировка «Валюта» в строке детализации (шапка варианта 2 «Детализация по субсчетам: …, Валюта») совпадает с меткой валюты шапки и порождает ложное предупреждение «валюта «» не распознана» (`currency = null` в обоих случаях). Править парсер (различать область шапки и строку детализации)? | tabular-parser: O-8, T12.1 (синтетика s16) | срез 2a+ (`header.py`) | допущение принято: дефект малозначим, предупреждение зафиксировано в golden-снимке s16 |
| OQ-105 | Сверка «сумма детей = родитель» по нетто (R-21): у родителя сальдо свёрнуто (счёт — одна сторона), у детей — развёрнуто. Подтвердить аналитиками, что нетто-свёртка родителя — штатное поведение 1С для **всех** версий выгрузки, а не артефакт двух организаций; остаточные риски RR-1…RR-4 и слепые зоны (перестановка сторон и одинаковое увеличение обеих сторон сальдо ребёнка под свёрнутым прямым родителем не ловятся ничем) перечислены в §1.4 | tabular-parser: R-21 (design №31), `05-domain-rules.md` §1.4 | срез 2a (контрольные суммы), срез 2c | допущение принято: нетто допустимо, если у родителя не более одной ненулевой стороны сальдо; 32 из 66 реальных файлов иначе не сходились |
| OQ-106 | «Итого = корень» сверяется с **первой** корневой строкой ACCOUNT (уровень 0). Лист с несколькими корнями уровня 0 (несколько счетов в одной ОСВ; ОСВ по субконто без строк-счетов) получает `needs_check`, хотя «Итого» может быть равна Σ корней. Сверять «Итого» с суммой корней? | tabular-parser: финальное ревью Среза 2a, M-4 | срез 2a+ (контрольные суммы `checks.py`), чек-лист документов | допущение принято: сверка с первым корнем; многокорневой лист → `needs_check` (аналитик проверяет вручную); в фикстурах таких листов нет |
| OQ-107 | При `hierarchy=sums` (дерево восстановлено по равенству «сумма детей = родитель») сверка «сумма детей = родитель» тавтологична — она не может не сойтись. Считать ли такой разбор `needs_check` (или отдельное предупреждение достаточно)? | tabular-parser: финальное ревью Среза 2a, M-5 | срез 2a+ (статус результата), срез 2c (UI проверки) | допущение принято: статус — по контрольным суммам, стратегия видна в `metrics.warnings` (`hierarchy=sums`); на реальных файлах стратегия не выбирается (все — `outline`/`trivial`) |
| OQ-108 | Защита фазы чтения xlsx от XML-бомб (entity expansion) — `defusedxml` как новая зависимость сервиса — и иные лимиты памяти чтения. Остаточные риски после R-23/R-25: (1) бомба по объёму самих ячеек в пределах лимита распакованного zip — xlsx в 5 МБ с 2 млн обычных ячеек читается openpyxl 13 с и занимает 1,5 ГБ (замер ревьюера); (2) объём `sharedStrings` и стилей в тех же пределах; (3) entity expansion (от экспоненты «billion laughs» защищает только сам expat ≥ 2.4); (4) **известный обход предсканирования (R-26):** лист в части без `.xml` с ≥ 64 байтами ведущих пробелов и без XML-декларации не попадает в скан (файл 1,8 КБ → 27 с / 1,4 ГБ в openpyxl) — первый follow-up: убрать «sniff» и отдавать expat каждую некаталожную запись; (5) скан сам не ограничен размером чанка: «мусорная» XML-часть с атрибутом в 400 МБ → 1,3 ГБ / 1,2 с, 400 МБ тегов → 8,5 с CPU; (6) xls с очень многими крупными merged-диапазонами (> 10 000 ячеек каждый) — линейный просмотр индекса `Sheet` (≈ 4,4 мс на обращение), останавливает только `PARSE_TIMEOUT_SECONDS`: потолок на число таких диапазонов. Предскан — эшелон защиты, не граница безопасности; настоящая граница — лимит памяти контейнера / подпроцесс с RLIMIT. Рекомендация: жёсткий лимит памяти контейнера сервиса; и/или предпроверка объявленного `<dimension>` и потоковый счётчик ячеек `<c>`/строк `sharedStrings` (тем же expat-сканом, что объединения) с лимитом; и/или разбор книги в подпроцессе с `RLIMIT_AS`/таймаутом, который можно убить. Решение платформы | tabular-parser: финальное ревью Среза 2a, I-1 (хвост), R-23, R-25 | tabular-parser (readers), платформа (зависимости и лимиты образа/пода) | допущение принято: действуют лимиты R-23/R-25 — распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек книги ≤ 200 000 (потоковый expat-скан всех XML-частей до openpyxl); объём ячеек, `sharedStrings`/стили и entity expansion не ограничены (README «Известные ограничения») |
| OQ-109 | Воркер tabular-parser скачивает объект S3 целиком и только потом проверяет `PARSE_MAX_BYTES`; проверять размер по `stat` до скачивания | tabular-parser: финальное ревью Среза 2a, M-7 | tabular-parser (`core/worker.py`) — вне Среза 2a | открыт: правка воркера — follow-up; лимит размера сейчас срабатывает после скачивания (память под файл выделяется) |
Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения;
`DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4.

View File

@ -74,21 +74,62 @@ UI справочника по макету) разделён: всё, кром
## Срез 2 — Загрузка файлов + табличный парсер (ОСВ, ББ, ОФР из Excel) — самый рискованный, делать рано
Решение владельца 05.10.2026: срез делится на четыре подсреза со своими change'ами и PR; сначала **только парсер ОСВ**.
Порядок: 2a → 2b → 2c → 2d. DoD среза в целом (без правил под конкретный файл, без координат в коде парсера) — в конце раздела.
### Срез 2a — Парсер ОСВ (`tabular-parser`) — реализован, ожидает merge
Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabular-parser#5, PR #6), сквозной
`docs/openspec/changes/slice-2a-osv-parser` (arm-ka-docs#9), фикстуры arm-ka-fixtures#4. Копия схемы v1.1 в `arm-core` —
отдельный bounded-PR без логики после merge docs и tabular-parser.
**Сделано (по факту):**
- **Парсер ОСВ** `tabular-parser/api/src/parser/`: динамическое обнаружение структуры по содержимому (§1.0): листы ОСВ,
заголовок таблицы и колонки сумм по тексту (обязательны «обороты» и «сальдо на конец»), шапка и `group_schema`
(варианты 1 и 2), иерархия каскадом `outline → indent → account_pattern → sums` (+ `trivial` для пустой ОСВ),
классификация строк, `Decimal`, контрольные суммы §1.4, статусы и коды (`05-domain-rules.md` §1.6), лимиты
`PARSE_MAX_BYTES` (50 МиБ) и `PARSE_TIMEOUT_SECONDS` (120 с). Чтение XLSX/XLS/XLSB по сигнатуре.
- **Словари** `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (данные, не код) с проверкой при старте и
тестом «каждая запись срабатывает хотя бы раз».
- **Контракт v1.1**: `rows[].extra` (нераспознанные колонки внутри таблицы); примеры `result-osv-minimal.json`,
`result-osv-needs-check.json`.
- **Результат на реальных данных:** все 66 файлов `fixtures/osv/**` — `done`, контрольные суммы сходятся, максимум 0,6 с
на файл; 16 синтетических файлов (`fixtures/osv/synthetic/`); 82 golden-снимка (`fixtures/golden/expected_osv_parse/`);
тест-линтер «нет координат в `src/parser/`»; CLI `python -m src.parser.cli <файл>`.
- **Нетривиальное решение (R-21):** сверка «сумма детей = родитель» по сальдо идёт по колонкам либо по нетто, если у
родителя сальдо свёрнуто (1С показывает счёт нетто, субсчета — развёрнуто); без него 32 из 66 файлов не сходились.
- **Допущения парсера, решаются в 2c:** `is_cumulative` только по тексту периода (OQ-02), `data_type = NU` как есть
(OQ-45), «Итого» выдаётся строкой `TOTAL` (OQ-01).
- **Документы:** `05-domain-rules.md` §1 приведён к факту (§1.6 — результат парсера), `06-test-data.md` §7 (синтетика,
golden, рукописный xlsb), OQ-98…OQ-105 открыты (финальное ревью добавило OQ-106…OQ-109), OQ-81 частично закрыт (в `tabular-parser` `ARM_KA_FIXTURES` поддержан; `scan-gateway` — follow-up).
- **Не сделано / риски:** xlsb проверен только рукописным синтетическим файлом (OQ-102); ИНН из текста строки не
извлекается (OQ-100); счета с буквой в первом сегменте не распознаются (OQ-103).
### Срез 2b — ББ/ОФР из Excel (`tabular-parser`)
- `tabular-parser`: ББ/ОФР из Excel по §12 в тот же `statementDocument`; поиск листов с формами по содержимому
(в 2a такие листы пропускаются с предупреждением `skipped: statement (slice 2b)`); словари синонимов `rules/*.yaml`;
контрольные формулы §7 для форм.
- Тесты: property-тесты инвариантов на **всех** файлах `fixtures/statements-xlsx/**` (формулы §7) + синтетика (ББ+ОФР на
одном листе, csv-подобный лист); снимки результата — рядом с golden ОСВ.
### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`)
- `arm-core`: `stored_file` + `s3_object` + `recognition_job` (outbox) + дедуп по `content_hash` + замена файла (`SUPERSEDED`).
- `tabular-parser`: **динамическое** обнаружение структуры (§1.0): поиск листов с ОСВ/ББ/ОФР по содержимому, заголовка
таблицы и колонок сумм по тексту, иерархии строк каскадом стратегий (outline level → отступ → шаблон кода → суммы),
шапки регулярками; классификация по `group_schema`; контрольные суммы; ББ/ОФР из Excel по §12 в тот же
`statementDocument`; словари синонимов `rules/*.yaml`; результат по контракту; NATS request/result; S3 `results/`.
- Маппинг результата в ядре: `document` (OSV) + `osv_entry` (все строки, OQ-01) + резолв `account_id`, `account_root`,
`counterparty_id` по ИНН/имени/алиасу; `document.period_check / counterparty_check`; статусы.
- Тесты: property-тесты инвариантов на **всех** файлах `fixtures/osv/**` и `fixtures/statements-xlsx/**` («Итого» =
корень; тождество сальдо/оборотов; Σ детей = родитель; формулы §7 для форм) + синтетика для форматов, которых нет в
фикстурах (шапка вариант 2, без outline level, csv-подобный лист, Вал./Кол., xlsb, ББ+ОФР на одном листе, текстовые
числа). Снимок результатов → `fixtures/golden/expected_osv_parse/`.
`counterparty_id` по ИНН/имени/алиасу; `document.period_check / counterparty_check`; статусы. Решения, отложенные
парсером: OQ-01 (хранение «Итого»), OQ-02 (нарастающие по данным), OQ-45 (`NU`), OQ-67, OQ-69, OQ-70 (маппер и
`recognition.result`), OQ-100 (ИНН из текста имени контрагента).
- Маппинг ББ/ОФР в ядре из `statementDocument` — общий для Excel и OCR (Срез 3 его переиспользует).
- UI: панель «Первичные документы», окно загрузки, таблица ОСВ с деревом строк и подсветкой нераспознанных контрагентов.
- DoD: все реальные файлы и вся синтетика проходят **без единого правила под конкретный файл или координату**; в коде
парсера нет литералов букв колонок/номеров строк/имён листов (проверяется тестом-линтером).
- Копия схемы контракта v1.1 в `arm-core` (`OsvRow.extra`) — если не сделана bounded-PR раньше.
### Срез 2d — UI «Первичные документы»
- Панель «Первичные документы», окно загрузки, таблица ОСВ с деревом строк и подсветкой нераспознанных контрагентов.
**DoD среза 2 целиком:** все реальные файлы и вся синтетика проходят **без единого правила под конкретный файл или
координату**; в коде парсера нет литералов букв колонок/номеров строк/имён листов (проверяется тестом-линтером).
## Срез 3 — ББ/ОФР: scan-gateway, маппинг форм, логические проверки, подтверждение документа

View File

@ -1,5 +1,5 @@
{
"schema_version": "1.0",
"schema_version": "1.1",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
@ -66,7 +66,8 @@
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
"closing_credit": "700.50",
"extra": { "КПП": "352801001" }
}
],
"checks": {

View File

@ -0,0 +1,125 @@
{
"schema_version": "1.1",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "1.0.0" },
"status": "needs_check",
"metrics": {
"rows": 3,
"warnings": [
"sheet=Лист_1 hierarchy=outline",
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
]
},
"documents": [
{
"document_type": "OSV",
"locator": "sheet:Лист_1",
"header": {
"organization_name": "ООО Ромашка",
"organization_inn": null,
"account_code_raw": "60",
"period_text": "1 квартал 2025 г.",
"period_year": 2025,
"period_quarter": "Q1",
"is_cumulative": false,
"data_type": "BU",
"currency": "RUB",
"header_variant": 1
},
"group_schema": [
{ "type": "ACCOUNT", "name": "Счет" },
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
],
"columns": {
"opening_debit": "D",
"opening_credit": "E",
"turnover_debit": "F",
"turnover_credit": "G",
"closing_debit": "H",
"closing_credit": "I",
"inn": "B"
},
"rows": [
{
"row_number": 1,
"level": 0,
"parent_row_number": null,
"row_value": "60",
"group_type": "ACCOUNT",
"group_name": "Счет",
"account_code_raw": "60",
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
},
{
"row_number": 2,
"level": 1,
"parent_row_number": 1,
"row_value": "ООО Лютик",
"group_type": "COUNTERPARTY",
"group_name": "Контрагенты",
"account_code_raw": "60",
"counterparty_name": "ООО Лютик",
"counterparty_inn": "7711111111",
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50",
"extra": { "КПП": "352801001" }
},
{
"row_number": 3,
"level": 2,
"parent_row_number": 2,
"row_value": "Вал.",
"group_type": "OTHER",
"group_name": null,
"account_code_raw": "60",
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "CURRENCY",
"opening_debit": "0.00",
"opening_credit": "400.00",
"turnover_debit": "100.00",
"turnover_credit": "50.00",
"closing_debit": "0.00",
"closing_credit": "400.00"
},
{
"row_number": 4,
"level": 0,
"parent_row_number": null,
"row_value": "Итого",
"group_type": "TOTAL",
"group_name": null,
"account_code_raw": null,
"counterparty_name": null,
"counterparty_inn": null,
"data_type": "BU",
"opening_debit": "0.00",
"opening_credit": "1000.00",
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
"closing_credit": "700.50"
}
],
"checks": {
"total_row_matches_root": true,
"balance_identity_violations": [3],
"parent_sum_violations": []
}
}
]
}

View File

@ -1,13 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result/v1",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.1",
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.1 пишет \"1.1\"" },
"job_id": { "type": "string", "format": "uuid" },
"file_id": { "type": "string", "format": "uuid" },
"service": {
@ -114,7 +114,8 @@
"turnover_debit": { "$ref": "#/$defs/money" },
"turnover_credit": { "$ref": "#/$defs/money" },
"closing_debit": { "$ref": "#/$defs/money" },
"closing_credit": { "$ref": "#/$defs/money" }
"closing_credit": { "$ref": "#/$defs/money" },
"extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } }
}
}
},

View File

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

View File

@ -0,0 +1,27 @@
# Design: Срез 2a (сквозное, docs)
## Context
См. proposal. Дизайн парсера — в дочернем change `tabular-parser/openspec/changes/slice-2a-osv-parser/design.md` (D-1…D-10).
## Decisions
### D-1. MINOR 1.1 вместо «складывать extra в columns»
`columns` — диагностика документа, а не строки; `extra` нужен построчно. Новое необязательное поле — MINOR по правилу
версионирования, схема v1.0 остаётся валидной для старых файлов. *Альтернатива* — ждать 2.0: парсер потеряет данные.
### D-2. Декомпозиция Среза 2 фиксируется в плане срезов
2a парсер ОСВ → 2b ББ/ОФР из Excel → 2c загрузка и маппинг в ядре (включая решения OQ-01/02/45/67/69/70) → 2d UI.
Каждый подсрез — свой набор change'ей и PR.
### D-3. Golden-снимки живут в fixtures, формат — файл результата без volatile-полей
Так снимки обновляются вместе с данными и видны всем сервисам; `job_id`, `file_id`, `service.version`, `duration_ms`
исключены, чтобы снимок зависел только от файла и кода парсера.
## Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-05 | D-1: контракт MINOR 1.1 с `rows[].extra` | §1.2; обратно совместимо | откат поля |
| 2 | 2026-10-05 | D-2: обозначения подсрезов 2a–2d в плане | владелец утвердил объём 2a, остальное — порядок по плану | переименовать |
| 3 | 2026-10-05 | D-3: формат и место golden | см. выше | перенос каталога |
| 4 | 2026-10-05 | Копия схемы v1.1 в arm-core — bounded-PR без логики, после merge docs и tabular-parser | тест идентичности в arm-core | нет |

View File

@ -0,0 +1,46 @@
# Proposal: Срез 2a (сквозное) — контракт результата v1.1, уточнения §1, фикстуры, OQ
**Срез:** 2, подсрез 2a «парсер ОСВ» (решение владельца 05.10.2026: сначала только парсер, без ББ/ОФР из Excel, ядра и UI).
**Репозиторий:** arm-ka-docs. **Issue:** RSHB/arm-ka-docs#9. **Дочерний change:** `tabular-parser/openspec/changes/slice-2a-osv-parser`
(RSHB/arm-tabular-parser#5). Фикстуры: RSHB/arm-ka-fixtures#4.
## Why
§1.2 требует «неизвестные колонки сохранять в `extra`, не терять», а в контракте v1.0 у строки ОСВ такого поля нет —
парсер не может выполнить правило без MINOR-версии контракта. Кроме того, после реального прогона на 66 файлах §1 и
06-test-data нужно привести к факту, а план срезов — к принятой декомпозиции Среза 2.
## What Changes
- **Контракт v1.1** (`docs/contracts/recognition-result.schema.json`): `osvDocument.rows[].extra` — объект
«заголовок колонки → текст ячейки или null» для колонок внутри таблицы, не сопоставленных с суммами и ИНН; `title`
и `description` `schema_version` — «1.1»; пример `examples/result-osv-minimal.json` дополняется `extra`; новый пример
`examples/result-osv-needs-check.json` (нарушение §1.4). Сообщение и запрос не меняются. Спека `recognition-contract`:
требование «Файл результата» — v1.1.
- **`05-domain-rules.md` §1**: уточнения по факту реализации (статусы и коды D-6, формула «стратегия иерархии в
`metrics.warnings` как `hierarchy=<имя>`», TOTAL в `rows`, лимиты парсера, словари по именам файлов), §1.5 — пометка
`ASSUMPTION(OQ-02)`.
- **`06-test-data.md`**: синтетика `osv/synthetic/` (перечень файлов и что проверяет каждый), golden `expected_osv_parse/`
(формат снимка, процесс обновления), xlsb вручную.
- **`09-delivery-plan.md`**: Срез 2 разбит на 2a (парсер ОСВ), 2b (ББ/ОФР из Excel), 2c (загрузка и маппинг в ядре),
2d (UI «Первичные документы»); `08-source-docs-index.md`/README без изменений.
- **`07-open-questions.md`**: новые OQ (коды валют для строк CURRENCY; поведение при нескольких ОСВ одного счёта в одной
книге), статус OQ-81 (частично закрыт: в tabular-parser `ARM_KA_FIXTURES` поддержан хелпером, scan-gateway — follow-up), OQ-45/OQ-01/OQ-02 — пометка «допущение парсера, решение в 2c».
## Capabilities
### Modified Capabilities
- `recognition-contract`: «Файл результата» — v1.1 с `rows[].extra`; «Версионирование контракта» — без изменений
(MINOR по правилу).
## Impact
- Контракт: MINOR, обратно совместимо; копии обновляются в `tabular-parser` (в дочернем change) и `arm-core` (отдельный
bounded-PR: только файл схемы и `OsvRow.extra`, логики нет). Порядок merge: docs → tabular-parser → arm-core.
- Таблицы DBML: нет. Правила: §1 (уточнения без смены смысла), §1.5 (допущение).
## Non-goals
- §12 (ББ/ОФР из Excel) и `statementDocument` — не трогаются.
- Изменение семантики `is_cumulative`, `TOTAL`, `NU` в ядре — подсрез 2c (OQ-02, OQ-01, OQ-45).
- `audit_action` для статусов задания (OQ-55), дедуп по attempt (OQ-73).

View File

@ -0,0 +1,21 @@
# Spec Delta: recognition-contract
## MODIFIED Requirements
### Requirement: Файл результата
Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v1.1: один файл (`stored_file`)
→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки
`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`.
У строки `osvDocument.rows[]` SHALL быть необязательное поле `extra` — объект «заголовок колонки → строка или null» для
колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»); потребители, не знающие поля,
игнорируют его (MINOR). Производитель v1.1 пишет `schema_version = "1.1"`; файлы v1.0 без `extra` остаются валидными.
#### Scenario: Результат валидируется примерами
- **WHEN** CI сервиса запускает тесты контракта
- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата, а все
`request-*.json` / `result-message-*.json` — схемы сообщений
#### Scenario: Лишняя колонка сохранена
- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП»
- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v1.1 и схему v1.0 (поле
игнорируется)

View File

@ -0,0 +1,11 @@
# Tasks: Срез 2a (docs)
- [x] 1.1 Контракт v1.1: `contracts/recognition-result.schema.json` (`rows[].extra`, title/description 1.1), примеры
`result-osv-minimal.json` (+`extra`, `schema_version 1.1`) и новый `result-osv-needs-check.json`; `openspec validate --strict`;
проверка схемой через `jsonschema` (скрипт в `dev/checks.md`, если есть, иначе вручную в отчёте).
- [x] 1.2 `09-delivery-plan.md`: Срез 2 → подсрезы 2a–2d с составом каждого; `07-open-questions.md`: новые OQ (коды валют
CURRENCY-строк; несколько ОСВ одного счёта в книге — OQ-98…OQ-105), OQ-81 частично закрыт (в tabular-parser `ARM_KA_FIXTURES` поддержан; scan-gateway — follow-up), пометки «допущение парсера» у OQ-01/02/45.
- [x] 2.1 После реализации парсера (дочерний change, задачи 3.1–4.3): §1 `05-domain-rules.md` — статусы/коды, `hierarchy=<имя>`
в warnings, TOTAL в rows, лимиты, имена словарей; §1.5 — `ASSUMPTION(OQ-02)`; `06-test-data.md` — синтетика, golden,
xlsb; сверка: сценарии спеки `osv-parsing` ↔ пункты §1 (таблица в отчёте).
- [ ] 2.2 После merge дочернего change: `openspec archive slice-2a-osv-parser`; напоминание про bounded-PR копии схемы в arm-core.