diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 9bfb98b..e3a8431 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -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=`. Строка «Итого» в каскаде не участвует + (всегда уровень 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) diff --git a/context/06-test-data.md b/context/06-test-data.md index 29dac97..1e75c8c 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -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). diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 728a274..be63c65 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -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 | Шлюз читает ровно `//.json` (спека); старый код читал все файлы каталога `Output//`. Какие файлы и когда реальная ИС кладёт в `Output`, в каком порядке (момент готовности)? | scan-gateway: п. 5 таблицы предложений (T8.5) | срез 3 (стенд ИС) | **закрыт человеком 04.10.2026:** ИС кладёт **все распознанные документы одновременно** в каталог ``, по одному 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. Рекомендация: жёсткий лимит памяти контейнера сервиса; и/или предпроверка объявленного `` и потоковый счётчик ячеек ``/строк `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. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 36683b7..4209962 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -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, маппинг форм, логические проверки, подтверждение документа diff --git a/contracts/examples/result-osv-minimal.json b/contracts/examples/result-osv-minimal.json index 6b6d122..f883a8d 100644 --- a/contracts/examples/result-osv-minimal.json +++ b/contracts/examples/result-osv-minimal.json @@ -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": { diff --git a/contracts/examples/result-osv-needs-check.json b/contracts/examples/result-osv-needs-check.json new file mode 100644 index 0000000..81d64a8 --- /dev/null +++ b/contracts/examples/result-osv-needs-check.json @@ -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": [] + } + } + ] +} diff --git a/contracts/recognition-result.schema.json b/contracts/recognition-result.schema.json index c5eea87..5d329a8 100644 --- a/contracts/recognition-result.schema.json +++ b/contracts/recognition-result.schema.json @@ -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"] } } } } }, diff --git a/openspec/changes/slice-2a-osv-parser/.openspec.yaml b/openspec/changes/slice-2a-osv-parser/.openspec.yaml new file mode 100644 index 0000000..e3966d7 --- /dev/null +++ b/openspec/changes/slice-2a-osv-parser/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/slice-2a-osv-parser/design.md b/openspec/changes/slice-2a-osv-parser/design.md new file mode 100644 index 0000000..dc623f6 --- /dev/null +++ b/openspec/changes/slice-2a-osv-parser/design.md @@ -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 | нет | diff --git a/openspec/changes/slice-2a-osv-parser/proposal.md b/openspec/changes/slice-2a-osv-parser/proposal.md new file mode 100644 index 0000000..b5bd58f --- /dev/null +++ b/openspec/changes/slice-2a-osv-parser/proposal.md @@ -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). diff --git a/openspec/changes/slice-2a-osv-parser/specs/recognition-contract/spec.md b/openspec/changes/slice-2a-osv-parser/specs/recognition-contract/spec.md new file mode 100644 index 0000000..cc96d96 --- /dev/null +++ b/openspec/changes/slice-2a-osv-parser/specs/recognition-contract/spec.md @@ -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 (поле + игнорируется) diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md new file mode 100644 index 0000000..0173564 --- /dev/null +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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.