Срез 0: контракт v1.0, compose-окружение (профиль services), проверки качества, OQ-48…OQ-84 #3

Merged
aep merged 68 commits from feat/1-slice-0-skeleton into main 2026-10-04 18:39:31 +03:00
29 changed files with 1750 additions and 54 deletions

5
.gitignore vendored
View File

@ -1 +1,6 @@
.DS_Store
.codegraph/
.superpowers/
.bounded
dev/.env
.venv/

23
Makefile Normal file
View File

@ -0,0 +1,23 @@
# Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория.
# Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md.
VENV := .venv
# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должен быть jsonschema)
PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3)
CHANGE ?= slice-0-skeleton
.PHONY: venv validate-contracts validate-openspec check
# Единственная зависимость скриптов проверки контракта — jsonschema
venv:
uv venv --python 3.11 --allow-existing $(VENV)
uv pip install --python $(VENV)/bin/python jsonschema
# Примеры contracts/examples/ против трёх схем JSON Schema 2020-12
validate-contracts:
$(PY) scripts/validate_contracts.py
# Спека текущего изменения OpenSpec (нужен CLI openspec в PATH)
validate-openspec:
openspec validate $(CHANGE) --strict
check: validate-contracts validate-openspec

View File

@ -28,7 +28,7 @@
(вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема.
- **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**,
пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Контракт результата —
`docs/contracts/recognition-result.schema.json` (черновик; финализировать в срезе 2). Правила распознавания
`docs/contracts/recognition-result.schema.json` (контракт v1.0). Правила распознавания
заголовков/группировок — `rules/*.yaml`.
- **scan-gateway** — адаптер ИС Сканирование: переименовать файл `UUID_имя.pdf`, положить в «Входящие», опросить
«Исходящие/UUID/UUID.json», сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), нормализовать
@ -38,23 +38,32 @@
## Контракт обмена через NATS (DECISION-4)
Схемы — `docs/contracts/` (v1.0): `recognition-request.schema.json` и `recognition-result-message.schema.json` — тела
сообщений, `recognition-result.schema.json` — файл результата в S3; примеры — `docs/contracts/examples/`.
Схемы — `docs/contracts/` (v1.0), JSON Schema 2020-12: [`recognition-request.schema.json`](../contracts/recognition-request.schema.json)
и [`recognition-result-message.schema.json`](../contracts/recognition-result-message.schema.json) — тела сообщений,
[`recognition-result.schema.json`](../contracts/recognition-result.schema.json) — файл результата в S3; примеры —
[`docs/contracts/examples/`](../contracts/examples/). Примеры проверяются схемами: `python scripts/validate_contracts.py`
(из корня репозитория docs). Примеры — иллюстрации формы сообщений, а не готовые к исполнению задания:
схема проверяет у `reply_deadline` только формат `date-time` и не сравнивает срок с текущим временем, поэтому
`request-image.json` проходит схему при `reply_deadline` в прошлом. Срок проверяет сервис: `scan-gateway` отвечает
`error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование; `tabular-parser` срок не проверяет.
Файлы `examples/` копируются в сервисы побайтно, поэтому пример не правится ради «свежей» даты.
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` |
|---|---|---|---|---|
| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` |
| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `error {code, message}`, `service {name, version}`, `result_s3 {bucket, key}`, `result_schema_version`, `raw_s3_key` (только scan-gateway), `metrics` | `result:<job_id>:<attempt>` |
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | Схема |
|---|---|---|---|---|---|
| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
| ack | `recognition.result` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
`group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге.
Конверт со `status = processing` — подтверждение взятия в работу (ack), `Nats-Msg-Id = ack:<job_id>:<attempt>`.
**На платформе NATS без JetStream** (ответ 03.10.2026): нет durable consumer'ов, серверного дедупа и redelivery.
Поэтому гарантии доставки — на уровне приложения: сервисы подписаны queue group'ами (`tabular-parser`, `scan-gateway`),
ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении запроса публикует ack `processing`;
ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении корректного запроса публикует ack `processing`;
ядро, не получив ack за `ACK_TIMEOUT_SECONDS` (30), публикует запрос повторно с тем же `Nats-Msg-Id`, не более
`OUTBOX_MAX_REPUBLISH` (3) раз, затем `dlq`. Сервис держит process-local множество `job_id` в работе и на повторный
запрос отвечает ack'ом без второй обработки. Появится JetStream — меняются только publisher/subscriber.
запрос отвечает ack'ом без второй обработки. Публикация ack и результата сервисом при временной ошибке повторяется
с тем же `Nats-Msg-Id`: первая попытка плюс `PUBLISH_RETRIES` повторов (по умолчанию 3, всего до 4 попыток); повторы
исчерпаны — error-лог, задание закроет ядро (по `reply_deadline`). Появится JetStream — меняются только publisher/subscriber.
Правила:
- `recognition_job` создаётся в статусе `queued` **в одной транзакции** со `stored_file`; фоновый публикатор переводит в
@ -63,6 +72,18 @@
- Результат принимается подпиской queue group; `result_msg_id UNIQUE` = дедуп. Нет ack после повторов → `dlq`;
потерянный результат → `reply_deadline` → `timeout`; replay = сброс в `queued` с `attempt + 1`.
- Результат одного файла содержит **N документов**; ядро на каждый создаёт `document` и маппит данные.
- Три формы `Nats-Msg-Id`: запрос `<job_id>:<attempt>`, ack `ack:<job_id>:<attempt>`, результат `result:<job_id>:<attempt>`.
Ack (`processing`) сервис публикует **до чтения файла из S3**; повторная публикация запроса ядром идёт с тем же
`Nats-Msg-Id` и тем же `attempt`. Дубликат результата ядро определяет по `recognition_job.result_msg_id`.
- `schema_version` — `MAJOR.MINOR`; ядро и сервисы принимают тот же MAJOR и любой MINOR. Сервис отвергает запрос с
неизвестным MAJOR (как и запрос не по схеме) результатом `error` с кодом `SCHEMA_MISMATCH` **без предшествующего ack
`processing`**: финальный конверт сам закрывает задание в ядре (`published → error`), повторной доставки не будет.
Конверт публикуется, только если из тела извлекаются корректные `job_id` и `file_id`; для `job_id`, уже находящегося в
работе или обработанного, он не публикуется (его `Nats-Msg-Id` совпал бы с настоящим результатом). Нечитаемый запрос
(не JSON, нет `job_id`) остаётся без ответа: сервис пишет лог и отбрасывает его, ядро не получает ack, повторяет
публикацию `OUTBOX_MAX_REPUBLISH` раз и закрывает задание в `dlq` с `error_code = NO_ACK`. Новый MINOR раскатывается сначала на потребителей
(ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают
незнакомые значения.
- Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв
контрагентов, проверки — в ядре.

View File

@ -363,17 +363,36 @@ Policy-слой (не права): KA_RF/RKA/CLIENT_MANAGER видят толь
## §11. Маппинг JSON ИС Сканирование (FNR01/FNR02) → формы
Контракт (по ТЗ и фикстурам `aep_*.json`): `file_uid`; `error {code, message} | null`
(FILE_READ_ERROR, INVALID_FORMAT, INVALID_DOCUMENT, RECOGNITION_FAILED, UNKNOWN_ERROR); `data_ocr[]` по документам:
`label.form_name` («Бухгалтерский баланс» → BALANCE, «Отчет о финансовых результатах» → INCOME), `organization.full_name`,
`inn.inn`, `units.measurement`, `date`, `table_structure.rows[]` (`row_index`, `cells[] {column_index, value, confidence}`).
Строка `rows[0]` — заголовки колонок: cells[0] «Наименование показателя», [1] «Код», [2..] даты колонок.
`confidence` — в ТЗ доли 0..1, в реальных файлах проценты 0..100 **[открыто]** — нормализовать в `numeric(5,2)` 0–100.
Формат ответа — **по реальным файлам** `fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json` (проверено 04.10.2026; ТЗ
и первая редакция этого параграфа расходятся с ними — см. OQ-13, OQ-14, OQ-77):
Маппинг: `data_ocr[i]` → `document` (BALANCE/INCOME, `locator = pages:…`) + `financial_statement_meta` (ИНН, имя,
units, report_date, okud) + `financial_statement_column` (из `rows[0].cells[2..]`, `period_date` распарсить из
«На 30 сентября 2025 г.», `is_current` = первая) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя; только
строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`).
- Каталог ответа `<job_id>/` содержит **несколько json** — по одному на распознанный документ файла (пример: `aep_3.json` —
успех, `aep_3_ofr.json` — ошибка по второму документу того же PDF). Шлюз читает все json каталога и склеивает в один ответ
(`raw/{job_id}.json`: `{job_id, files[{name, response, parse_error}]}`); ошибка одного файла не отменяет остальные.
- Корень файла: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`** (поле `error` не `null`, а
`{"code": "", "message": ""}`); ошибка — непустой `code` (`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`), `data_ocr` с пустыми полями и `table_structure: []`.
- `data_ocr` — **объект** (не список): `label`, `organization`, `inn`, `units`, `date` — каждый `{form_name, confidence}`
(в `form_name` шумный OCR-текст; `date.form_name` ненадёжна, ИНН может быть мусором), `table_structure` — **список таблиц**
`{label {form_name, confidence}, rows[]}`: `label.form_name` «Бухгалтерский баланс» → BALANCE, «Отчет о финансовых
результатах» → INCOME. **Наблюдается:** в одном json может быть **несколько таблиц разных форм** (в `aep_3.json` и
`aep_6.json` — баланс и ОФР), каждая со своим `label`; тип документа задаёт `label`. Разбиение одной формы на несколько
таблиц (сегменты) в образцах **не встречалось** и не предполагается; если одна форма вдруг придёт двумя таблицами или
`label` повторится — это нештатный случай: документ не создаётся молча, а уходит в `NEEDS_CHECK` с причиной (решение —
Срез 3, OQ-77). Неизвестный `label` → `OTHER`.
- `rows[]`: `row_index`, `cells[] {column_index, value, confidence}`; `column_index` не обязательно подряд (бывают 0, 1, 2, 4, 5),
`value` — сырой OCR-текст (мусор в ячейках, переносы строк, скобки для отрицательных, пробелы-разделители тысяч).
В `aep_6.json` `rows[0]` — строка-шапка («Наименование показателя», «Код», «На 30 июня 2025 г», …); в `aep_3.json` шапки
**нет** — первая строка данных. Бывают строки без кода (расшифровки «в том числе…», OQ-36) и код-строка внутри ячейки
наименования («АКТИВ / I. Внеоборотные активы / Гудвил» с кодом 1105).
- `confidence` — **числа 0–100** (в ТЗ — доли 0..1; в реальных файлах проценты) → `numeric(5,2)` как есть.
Маппинг (Срез 3): таблица `table_structure[i]` с `label` «Бухгалтерский баланс» / «Отчет о финансовых результатах» →
`document` (BALANCE/INCOME, `locator = pages:…`) + `financial_statement_meta`
(ИНН, имя, units, report_date, okud — из `organization/inn/units/date.form_name`, с низкой уверенностью → `NEEDS_CHECK`) +
`financial_statement_column` (из шапки `rows[0]`, если она есть; `period_date` распарсить из «На 30 июня 2025 г», `is_current` =
первая; без шапки — колонки без дат, документ `NEEDS_CHECK`) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя;
только строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`).
Сверки: ИНН/имя vs `legal_entity` → `document.counterparty_check`; дата vs период версии → `period_check`.
---

View File

@ -146,15 +146,25 @@ fixtures/
statements-xlsx/ # ББ/ОФР в Excel (§12): лист «Отчетность» из Анализ*.xlsx,
# рекомендуемые формы банка, выгрузки 1С — собрать у аналитиков
ocr/
aep_N.pdf + aep_N.json # пары вход/выход ИС Сканирование
aep_N.pdf|PDF + aep_N.json # пары вход/выход ИС Сканирование (эмулятор ИС
# в scan-gateway ищет `<stem>.json` по имени входного
# файла без расширения, регистр его расширения для
# эмулятора неважен; важен путь самого входного файла
# `aep_5.PDF`: на регистрозависимой ФС (Linux) он ищется
# буквально)
golden/
Консолидация_Группа РБ.xlsx # эталон КФО по датам
expected_osv_parse/*.json # ожидаемый результат парсера (зафиксировать после ревью)
directories/
legal_entities.csv, company_groups.csv # тестовые справочники группы «Русский бисквит» (Срез 1)
```
Справочники **продукта** — план счетов, правила элиминации, права и роли, состав обязательных документов — не фикстуры,
а seed ядра: `arm-core/api/seed/` (Срез 0, change `slice-0-skeleton`). Здесь остаются только тестовые данные группы.
`directories/` содержит только тестовые справочники группы (ЮЛ и ГК «Русский бисквит»); до Среза 1 в нём лежит пустой
`.gitkeep`. Справочники **продукта** — план счетов, правила элиминации, права и роли, состав обязательных документов,
региональные офисы — не фикстуры, а seed ядра: `arm-core/api/seed/core/` (обязательные данные: `roles.yaml`,
`permissions.yaml`, `role_permissions.yaml`, `regional_office.yaml`, `chart_of_accounts.csv`, `elimination_rules_v1.yaml`,
`required_document_rules.yaml`) и `arm-core/api/seed/dev/` (только `python -m scripts.seed --dev`: dev-пользователи и
примеры РФ). Загрузчик — `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-набор.

View File

@ -18,8 +18,8 @@
| OQ-10 | Гудвилл: входит ли стр. 1350 (добавочный капитал) в базу участия; УК из ББ (1310) или из ОСВ 80/83 | DBML, DOC-259025, FNR03 | срез 6 | допущение: 1310 из ББ; 1350 — настраиваемый флаг, по умолчанию выключен |
| OQ-11 | Строка «ППА» в КФО (правило № 1, когда у А нет ОСВ 03) — что это и куда | FNR03 | срез 6 | не реализовывать |
| OQ-12 | Формулы ОФР: знаки 2410/2420/2430 по ФСБУ 4/2023 vs 66н; состав 1100 (1120–1140) | DOC-221281 | срез 3 | допущение: формулы §7, строки без значения = 0 |
| OQ-13 | `confidence` от ИС Сканирование — доли 0..1 или проценты 0..100; имя поля организации (`full_name` vs `form_name`) | FNR02 vs `aep_*.json` | срез 3 (scan-gateway) | допущение: нормализовать в 0–100 по фактическим файлам |
| OQ-14 | `reporting_date` в JSON ИС Сканирование — «ждём обновлённый JSON» | FNR01 | срез 3 | допущение: парсить из заголовка «на 30 сентября 2025 г.» |
| OQ-13 | `confidence` от ИС Сканирование — доли 0..1 или проценты 0..100; имя поля организации (`full_name` vs `form_name`) | FNR02 vs `aep_*.json` | срез 3 (scan-gateway) | **закрыт для scan-gateway (04.10.2026, по `aep_3.json`, `aep_6.json`):** `confidence` — числа 0–100 (не доли); название организации — `data_ocr.organization.form_name` (шумный OCR-текст), ИНН — `inn.form_name`, единицы — `units.form_name`, дата — `date.form_name` (в `aep_6.json` мусор); `full_name`/`inn.inn`/`units.measurement` из ТЗ в реальных файлах нет. Нормализация — Срез 3 |
| OQ-14 | `reporting_date` в JSON ИС Сканирование — «ждём обновлённый JSON» | FNR01 | срез 3 | **уточнено (04.10.2026):** отдельного поля `reporting_date` в ответе нет; `date.form_name` ненадёжен (в `aep_6.json` — «Общество с ограниченной ответственностью», в `aep_3.json` — «2\|0\|2\|4\|»); даты колонок — в строке-шапке `rows[0]` таблицы («На 30 июня 2025 г» в `aep_6.json`), в `aep_3.json` шапки нет. Допущение прежнее: парсить из шапки таблицы, при её отсутствии — `NEEDS_CHECK`; решение — Срез 3 |
| OQ-15 | Таймаут OCR 5 мин / 0 повторов (ТЗ 3) vs outbox с повторами (DBML) | DOC-037736 | срез 3 | решено DBML: `max_attempts` OCR = 1, парсер = 3 |
| OQ-16 | Матрица прав (ТЗ сноска 29 «будет разработана»); роли макета (Аналитик ДКБ, Наблюдатель) ≠ ТЗ (ANL-034) | ТЗ 8.1, макет | срез 1 | допущение: seed по §10 правил |
| OQ-17 | `user_role` M:N или одна роль на пользователя | DBML | срез 1 | допущение: M:N |
@ -50,9 +50,47 @@
| OQ-42 | «Проверка распознавания» для ОСВ (xlsx) в макете отсутствует (строки `doc-row--readonly`, `39`). Нужен ли отдельный экран проверки ОСВ (дерево строк, подсветка контрагентов) или проверка ОСВ только через сводную ОСВ (`30`)? | макет [39] | срез 2 (UI) | допущение: минимальный экран таблицы ОСВ по §1 правил без левой панели исходника |
| OQ-43 | Кнопка «К отчетности» неактивна у консолидаций в статусах «Инициация» и «Верификация РФ», активна у «Консолидация РФ» и «Верифицирован» (`34`). Каково правило доступности отчёта по статусу / роли? | макет [34] | срез 4, 7 (policy) | допущение: отчёт доступен с `CONSOLIDATION_RF` и далее для всех ролей с `CONSOLIDATION_VIEW`; «Инициация» — только карточка |
| OQ-44 | «Добавить версию» создаёт версию сразу без модалки (`43-01`), копируя периметр; нужно ли подтверждение и выбор сценария/подхода новой версии? | макет [43-01] | срез 7 | допущение: без модалки, периметр и сценарий копируются из предыдущей версии |
| OQ-45 | Контракт `recognition-result.schema.json` допускает `data_type = NU` (строки налогового учёта в ОСВ «БУ, НУ»), а enum DBML `osv_data_type` — только BU, CURRENCY, QUANTITY, SUM. Добавлять NU в DBML (v5.14) или не хранить строки НУ? | контракт v1.0 vs DBML | срез 2 (маппинг `osv_entry`) | допущение: контракт оставляет NU; решение по хранению — до начала Среза 2; код Среза 0 не затрагивает |
| OQ-46 | Обязательны ли все ОСВ расширенного подхода (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) при `approach = EXTENDED`, или часть — «при наличии»? | ТЗ 5.1; `required_document_rule.is_mandatory` | срез 0 (seed), срез 3 (чек-лист) | допущение: все `is_mandatory = true` для EXTENDED |
| OQ-47 | Источник полного плана счетов 1С для seed `chart_of_account`: в фикстурах файла нет, «план счетов из бандла макета» не передан | `06-test-data.md` §6, `fixtures/README.md` | срез 0 (seed) | допущение: План счетов 94н (01–99, 001–011) + типовые субсчета 1С БП 3.0, упомянутые в §4/§6; остальное добавляет администратор (ARCH-004) |
| OQ-45 | Контракт `recognition-result.schema.json` допускает `data_type = NU` (строки налогового учёта в ОСВ «БУ, НУ»), а enum DBML `osv_data_type` — только BU, CURRENCY, QUANTITY, SUM. Добавлять NU в DBML (v5.14) или не хранить строки НУ? | контракт v1.0 vs DBML | срез 2 (маппинг `osv_entry`) | допущение принято и применено в Срезе 0: контракт v1.0 оставляет `NU` (в копиях схем трёх сервисов `DataType` содержит `NU`), ORM `OsvDataType` в arm-core 1:1 с DBML v5.13 (BU, CURRENCY, QUANTITY, SUM), маппинг результата в `osv_entry` — `NoopMapper`, поэтому расхождение Срез 0 не затрагивает; решение (кандидат DBML v5.14: добавить `NU` в `osv_data_type`, либо не хранить строки НУ) — до начала Среза 2; источник: arm-core, предложения, раздел C |
| OQ-46 | Обязательны ли все ОСВ расширенного подхода (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) при `approach = EXTENDED`, или часть — «при наличии»? | ТЗ 5.1; `required_document_rule.is_mandatory` | срез 0 (seed), срез 3 (чек-лист) | допущение принято и применено в Срезе 0: все 27 строк EXTENDED в seed `arm-core/api/seed/core/required_document_rules.yaml` (маркер `ASSUMPTION(OQ-46)`) — `is_mandatory = true`; BASIC — 10 строк, все обязательные; смена правила — правка одного yaml; вопрос к методологам остаётся открытым до Среза 3 (чек-лист); источник: arm-core, предложения, раздел A |
| OQ-47 | Источник полного плана счетов 1С для seed `chart_of_account`: в фикстурах файла нет, «план счетов из бандла макета» не передан | `06-test-data.md` §6, `fixtures/README.md` | срез 0 (seed) | допущение принято и применено в Срезе 0: seed `arm-core/api/seed/core/chart_of_accounts.csv` — 122 строки (62 синтетических счёта Плана счетов 94н, 11 забалансовых, 49 субсчетов и подсубсчетов 1С БП 3.0, упомянутых в §4/§6); названия нестандартных субсчетов (66/67 `.06/.22/.24/.26`, 76.07.x, 76.08/.28/.38) выведены по их роли в §4 правил, а не взяты из справочника 1С; названия-заглушки заменить по справочнику 1С клиента, остальное добавляет администратор (ARCH-004); источник: arm-core, предложения, раздел A |
| OQ-48 | Правило элиминации № 1 (§4), сторона Б «76.07.1 − 76.07.5 − 76.07.9»: 76.07.5 и 76.07.9 не потомки 76.07.1, поэтому «вычитание» чисто информативно. Так и задумано? | `05-domain-rules.md` §4 (правило 1); seed `elimination_rules_v1.yaml`; arm-core: предл. 1 (T14.1) | срез 5 (пары ВГО) | допущение принято: исключения 76.07.5/76.07.9 на стороне Б хранятся как `is_excluded` и не влияют на сумму (76.07.1 их не содержит); алгоритм на них не опирается; подтвердить с методологами |
| OQ-49 | Правило № 1, сторона А: резервный счёт 01 лежит в одном списке с 03 и 76.07.9 — приоритета нет. Как выбирать «03, а если у А нет ОСВ 03 — 01»? Что значит «результат в строку ППА» (см. OQ-11)? | `05-domain-rules.md` §4 (правило 1: «01 — резервный»); seed `elimination_rules_v1.yaml`; arm-core: предл. 2 (T14.2) | срез 5–6 | допущение принято: в seed `accounts: ["03", "76.07.9", "01"]` без порядка; fallback «берём 01, если нет ОСВ 03» — в `comment` правила; логику приоритета реализует срез 5, до решения она не реализуется |
| OQ-50 | Составные коды статей КФО `1450/1550` (правило 1) и `1310/1350` (правило 7, гудвилл) хранятся в `elimination_rule.article_2_line` одной строкой с «/». Как их потреблять: делить по «/» и подбирать строку, которая есть в ББ Б, или хранить две строки? | `05-domain-rules.md` §4 (правила 1, 7); seed `elimination_rules_v1.yaml`; arm-core: предл. 3 (T14.3) | срез 5–6; кандидат DBML v5.14 (note колонки) | допущение принято: хранится как в §4, потребитель делит по «/»; предложение — зафиксировать это в note `article_2_line` в DBML v5.14 (версию не создавали) |
| OQ-51 | `required_document_rule`: уникальный индекс `(approach, document_type, account_id)` при nullable `account_id` допускает дубли строк для не-ОСВ документов (NULL в PostgreSQL различны). Добавить `NULLS NOT DISTINCT`? | DBML v5.13 (`required_document_rule`, Indexes); arm-core: предл. 4 (T8.3) | срез 3 (чек-лист); кандидат DBML v5.14 | допущение принято: ORM и миграция 1:1 с DBML v5.13 (без `NULLS NOT DISTINCT`); предложение v5.14: `[unique, note: 'NULLS NOT DISTINCT']`, как у `document` |
| OQ-52 | Контракт v1.0, `recognition-result-message`: `schema_version` принимает только `1.x` (`^1\.\d+$`), а `result_schema_version` — любой MAJOR (`^\d+\.\d+$`). Намеренно (файл результата версионируется отдельно) или небрежность? | `contracts/recognition-result-message.schema.json`; arm-core: предл. 5 (T20.7) | контракт v1.x; срез 2 (inbox, маппинг) | допущение принято: ядро повторяет схему буквально (любой MAJOR проходит валидацию); совместимость MAJOR результата проверяет маппинг Среза 2; уточнить у владельца контракта |
| OQ-53 | Контракт v1.0: схемы нигде не задают `additionalProperties: false` (кроме `columns` ОСВ). Считать ли неизвестные поля допустимыми расширениями? | `contracts/*.schema.json`; arm-core: предл. 6 (T20) | контракт v1.x; все три сервиса | допущение принято: модели ядра `extra = "ignore"` (строже схемы быть нельзя — ядро отвергнет совместимого пира); изменение — через MINOR контракта |
| OQ-54 | Параметры JWT РАИСА неизвестны: JWKS URL / публичный ключ, `iss`, `aud`, имя claim с email. Что в продуктивном контуре? Делать ли проверку `aud`/`iss` обязательной в проде? | ТЗ 8.1, `03-architecture.md` (SSO РАИСА); `core/auth.py`; arm-core: предл. 7 (T16.2) | срез 1+ (прод-конфигурация) | допущение принято: настраиваются `AUTH_JWKS_URL`/`AUTH_PUBLIC_KEY` (взаимоисключающие), `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM` (по умолчанию `email`); при пустых `AUTH_AUDIENCE`/`AUTH_ISSUER` `aud`/`iss` не проверяются; предложение: в проде сделать `aud` обязательным и предупреждать при старте, если пусто |
| OQ-55 | В enum `audit_action` нет действия для смены статуса задания распознавания (`recognition_job`). Добавлять ли (DBML v5.14) и нужен ли аудит обмена вообще? | DBML v5.13 `audit_action`; arm-core: предл. 8 (R-7, design #20 arm-core) | срез 2 (обмен); кандидат DBML v5.14 | допущение принято: Срез 0 не пишет `audit_event` из обмена `recognition_job`; сервис аудита (`services/audit.py`) готов и покрыт тестами; если нужно — действие в v5.14 и вызовы в `recognition_exchange.py` |
| OQ-56 | Append-only аудит обеспечен только на уровне кода (единственная точка записи, статический тест на UPDATE/DELETE по `AuditEvent`). Нужна ли защита на уровне БД (триггер или `REVOKE UPDATE, DELETE` для роли приложения)? Вопрос поднят дважды (в том числе финальным ревью ветки). | инвариант «Append-only аудит» (`../CLAUDE.md`); `services/audit.py`; arm-core: предл. 9 и дополнение к нему (задача 21) | миграции, права БД; платформа | **решено человеком 04.10.2026: осознанный техдолг.** Append-only на уровне кода и тестов; защита на уровне БД (триггер или `REVOKE UPDATE, DELETE`) — позже, вместе с платформой, до прода |
| OQ-57 | Счётчик повторных публикаций запроса (`ACK_TIMEOUT_SECONDS` × `OUTBOX_MAX_REPUBLISH`) хранится в `recognition_job.metrics.republish_count` (jsonb), колонки в DBML нет; при каждой повторной публикации `published_at` перезаписывается временем последней, время первой не сохраняется. Нужны ли колонка счётчика и время первой публикации (v5.14)? | `services/recognition_exchange.py` (design #6 arm-core); arm-core: предл. 10 (T23, T23.1); docs: O-19 (задача 4 фазы 2) | срез replay; кандидат DBML v5.14 | допущение принято: счётчик в `metrics.republish_count`, `attempt` при повторе не растёт; `published_at` — время последней публикации (`dlq` через (`OUTBOX_MAX_REPUBLISH`+1)×`ACK_TIMEOUT_SECONDS` ≈ 120 с, описано в `dev/README.md`); при будущем replay счётчик и `reply_deadline` надо сбрасывать |
| OQ-58 | Семантика сроков и ошибок обмена в ядре: (а) `reply_deadline` по умолчанию = `now + 5 мин` для обоих pipeline (`tabular`, `image`), если у задания NULL; в DBML срок есть только для OCR («OCR — 5 мин»), для парсера значения нет; (б) при невозможности собрать запрос по контракту задание уходит в `error` / `INVALID_REQUEST`; (в) при `ACK_TIMEOUT × (MAX_REPUBLISH+1) ≥ 5 мин` статус `dlq` недостижим (раньше сработает `timeout`). Верно ли? Какой срок у `tabular-parser`? | DBML `recognition_job.reply_deadline` note; `recognition_exchange.py` (`DEFAULT_REPLY_TIMEOUT`); arm-core: предл. 11 и дополнение (T23.4) | срез 2–3; настройка таймаутов | допущение принято: как описано (дефолты 30 с × 3 укладываются в 5 мин); сроки по pipeline уточнить с командами парсера и шлюза |
| OQ-59 | Email пользователя сопоставляется с `app_user.email` по `lower(email)`; при двух записях, различающихся регистром, — 403 (fail-closed). Добавить `CHECK`/уникальный индекс по `lower(email)` в DBML? | `core/auth.py`, `api/dependencies/auth.py`; arm-core: предл. 12 (R-17, design #29 arm-core) | срез 1 (админ-экран); кандидат DBML v5.14 | допущение принято: уникальность `email` регистрозависимая (как в DBML); регистронезависимая — в v5.14 |
| OQ-60 | Seed `--dev` не защищён флагом `DEBUG`: создаёт dev-пользователей (в том числе `ADMIN`) на любой БД, на которую указывает `.env`, и перезаписывает существующего `app_user` с совпадающим email. Нужна ли защита? | arm-core `scripts/seed.py`; arm-core: предл. 13 (T15.2, T15.4) | срез 0 (процесс); прод-развёртывание | допущение принято: спека защиты не требует, `--dev` помечен «только для разработки» (в compose запускается `arm-core-migrate`); предложение — отказ при `DEBUG=false` или явный флаг подтверждения |
| OQ-61 | Неверный метод на существующий API-маршрут (например `POST /api/v1/me`) отдаёт 404 вместо 405: SPA-монтирование на `/` перехватывает частичное совпадение, а `/api/*` в нём принудительно 404. Приемлемо? | arm-core `app.py` (`SPAStaticFiles`); arm-core: предл. 14 (T18.2) | API-контракт (клиенты, фронт) | допущение принято: оставлено как есть (честный 404 для несуществующих путей важнее 405); при необходимости — отдельная правка |
| OQ-62 | В образе `arm-core` нет фронта (`Dockerfile` не собирает `web/`), `GET /` отдаёт 307 на `//` и затем 404; редирект на `//` похож на дефект монтирования SPA при `ROOT_PATH=/`. Собирать `web/` в образ в Срезе 0/1 и чинить редирект? | arm-core `Dockerfile`, `app.py`; docs: O-10 (задача 3 фазы 2), `dev/README.md` | срез 0 (compose), срез 1 (фронт) | допущение принято: README фиксирует отсутствие UI в образе; редирект на `//` не исследовался (сервис не правился) — follow-up arm-core |
| OQ-63 | `web/package-lock.json`: шаблон lock не содержит, `raisa-*` из Nexus — lock вне банка отличался бы; в Срезе 0 lock в `.gitignore`. `npm install` сообщает 6 high (npm audit) в зависимостях шаблона web. Коммитить ли lock; кто обновляет зависимости? | arm-core `web/`; arm-core: предл. 15 (T25.1, R-23, design #35 arm-core) | платформа, фронт; до прода | допущение принято: lock не в репозитории, зависимости не менялись; решение — с платформенной командой (Nexus, политика audit) |
| OQ-64 | Аудит `LOGIN` / `LOGIN_FAILED` (действия есть в `audit_action`) для JWT-аутентификации: в каком срезе и что считать «входом» — первый запрос с токеном, каждый запрос, отказ проверки токена? | DBML `audit_action`; `core/auth.py`; arm-core: предл. 16 (финальное ревью) | срез 1 (вход, админ-экран); аудит | допущение принято: в Срезе 0 вход не аудируется; сервис аудита готов |
| OQ-65 | Шаблонные CORS `allow_origins=["*"]` + `allow_credentials=True` и `ProxyHeadersMiddleware(trusted_hosts=["*"])`: в проде любой клиент может подставить `X-Forwarded-For`, и `audit_event.ip_address` недостоверен. Какие origins и доверенные прокси на платформе? | arm-core `app.py` (шаблон команды); arm-core: предл. 17 (финальное ревью) | прод-конфигурация; достоверность аудита | допущение принято: оставлено как в шаблоне; перечни origins и доверенных прокси — с платформенной командой до прода |
| OQ-66 | `/docs` и `/openapi.json` FastAPI открыты без аутентификации, в том числе в проде. Закрывать их (выключать при `DEBUG=false`) или оставить? | arm-core `app.py` (шаблон); arm-core: предл. 18 (финальное ревью) | прод-конфигурация; безопасность | допущение принято: открыты, как в шаблоне; решение — с платформой и ИБ |
| OQ-67 | Inbox не обновляет `stored_file.status` (`UPLOADED → PROCESSING → PROCESSED / ERROR`) и `stored_file.current_recognition_job_id` по результату задания: после результата файл остаётся `UPLOADED`, `current_recognition_job_id` пуст. Кто и когда это делает в Срезе 2 — inbox или маппер, в одной транзакции? | DBML `stored_file`, `file_status`; `recognition_exchange.py`; arm-core: предл. 19 (финальное ревью); docs: O-18 (задача 4 фазы 2) | срез 2 (загрузка и маппинг) | допущение принято: в Срезе 0 (`NoopMapper`) файл остаётся `UPLOADED`, в `dev/README.md` записано как «не покрывается»; решение — при проектировании маппера Среза 2 |
| OQ-68 | Конверт `error` у `scan-gateway` публикуется без `metrics`, поэтому `recognition_job.metrics` = NULL (у `tabular-parser` в `metrics` есть `duration_ms`). Требовать ли `metrics` в конверте ошибки и единые поля метрик у обоих сервисов? | scan-gateway `core/worker.py`; docs: O-18 (задача 4 фазы 2, сквозная проверка) | срез 2–3 (наблюдаемость, контракт v1.x) | допущение принято: `metrics` в контракте необязательны, ядро хранит NULL; требование — решение владельца контракта |
| OQ-69 | Обработчик `recognition.result` выполняется последовательно в callback подписки NATS; длинный маппер Среза 2 задержит следующие сообщения, ack сервиса не успеет за `ACK_TIMEOUT_SECONDS` (30 с) — ложный republish, затем `dlq`. Предложение: развести быстрый inbox (фиксация сообщения и статуса) и отдельный цикл маппинга результата. | arm-core `core/nats.py` (`subscribe_results`), `recognition_exchange.py` (`handle_message`, `ResultMapper`); arm-core: предл. 20 (финальное ревью) | срез 2 (маппинг); архитектура обмена (D-6) | допущение принято: в Срезе 0 `NoopMapper` — проблема не проявляется; решение — до реализации маппера Среза 2 |
| OQ-70 | Перед чтением файла результата маппер должен проверять, что ключ начинается с `results/{file_id}/{job_id}/`, а бакет — бакет роли `results` (иначе сервис может «подсунуть» чужой объект). Закрепить это в контракте и `03-architecture.md`? | контракт `recognition-result-message` (`result_s3`); arm-core `_register_result_object` (`recognition_exchange.py`); arm-core: предл. 21 (финальное ревью) | срез 2 (маппинг); контракт v1.x | допущение принято: в Срезе 0 результат только регистрируется в `s3_object`, не читается; проверку реализует маппер Среза 2 |
| OQ-71 | Запрос без читаемых `job_id` / `file_id` / `attempt` остаётся без ответа (конверт собрать нельзя): сервис логирует ошибку и отбрасывает запрос, ядро закрывает задание `dlq` / `NO_ACK` примерно через 120 с. Расходятся и реализации: при нечитаемом `attempt` `tabular-parser` берёт его из заголовка `Nats-Msg-Id` запроса или ничего не отправляет, `scan-gateway` подставляет `attempt = 1` и публикует конверт. Оставить так, выровнять реализации или сделать `file_id` необязательным в конверте `error` (MINOR 1.1)? | `recognition-contract` spec, сценарий «Нечитаемый запрос»; tabular-parser: P-1 (задача 4.3); docs: O-24, design #35 | контракт v1.x; срез 2–3 | допущение принято: молчание сервиса (design #35); реализации выровнять в Срезе 2/3, влияние только на битые запросы |
| OQ-72 | Ack при несовместимой версии запроса: ранняя формулировка контракта и `03-architecture.md` требовали «`SCHEMA_MISMATCH` и ack», оба сервиса публикуют `error SCHEMA_MISMATCH` без ack (ядро принимает результат из `published`, ack перед ним ничего не добавляет). Спека и документ приведены к поведению сервисов — подтвердить. | tabular-parser: P-2; scan-gateway: п. 2 таблицы предложений (T12.13, R-16); docs: design #34 | контракт v1.x; срез 2 | допущение принято: без ack, результат `SCHEMA_MISMATCH` закрывает задание (design #34); если ядро начнёт требовать ack перед результатом — правка одной строки в воркерах и спеке |
| OQ-73 | Повторная попытка (`attempt + 1`) того же `job_id` после ошибки или результата: оба сервиса дедуплицируют по `job_id` (LRU `DONE_CACHE_SIZE`), поэтому replay с новым `attempt` получает только ack и не обрабатывается, на единственном инстансе задание повисает до `reply_deadline` → `timeout`. По `03-architecture.md` replay — «сброс в `queued` с `attempt + 1`». Сделать ключ дедупликации `(job_id, attempt)`; повтор во время обработки — только ack? | tabular-parser: P-3 (задача 4.3); scan-gateway: п. 17 таблицы предложений (I-3, R-17); `03-architecture.md` (replay) | срез 3+ (replay); контракт v1.x | вопрос: допущение принято на Срез 0 — дедуп по `job_id`, ядро повторяет только тем же `attempt`; при одобрении — одна строка в воркере (ключ LRU); **человеком 04.10.2026 не рассмотрен — вопрос остаётся открытым** |
| OQ-74 | `PUBLISH_RETRIES` = одна первая попытка + до 3 повторов (всего до 4) с тем же `Nats-Msg-Id`; у `scan-gateway` тип `PositiveInt` (повторы нельзя отключить настройкой), у `tabular-parser` `NonNegativeInt` (0 допустим); паузы 0,5 с и 2,0 с. Согласовать единую семантику (повторы или всего попыток) и тип? | scan-gateway: п. 1 таблицы предложений (R-13, T11.1); docs: O-25, design #36 | контракт v1.x; настройки сервисов | допущение принято: «1 + 3 повтора» (design #36), расхождение «3 против 4» обмен не ломает (дедуп по `Nats-Msg-Id`); выровнять тип — косметика конфигурации |
| OQ-75 | `reply_deadline` проверяет только `scan-gateway` (`error TIMEOUT` до отправки в ИС и потолок ожидания ответа); `tabular-parser` обрабатывает просроченный запрос. Пример `contracts/examples/request-image.json` имеет `reply_deadline` в прошлом (`2026-10-03T12:45:00Z`) и даёт `TIMEOUT` при ручном прогоне. Единая обязанность сервисов и свежая дата примера? | scan-gateway: п. 12 таблицы предложений (O-e); docs: O-26, design #37 | контракт v1.x; срез 2 | допущение принято: срок — дело сервиса, пример — иллюстрация формы (оговорка в spec и `03-architecture.md`, design #37); обновить дату можно одним PR с пересинхронизацией копий в трёх репозиториях |
| OQ-76 | Обе реальные фикстуры `fixtures/ocr` (`aep_5.json`, `aep_8_form4.json`) — ответы-ошибки ИС (`error.code = INVALID_DOCUMENT`, пустой `table_structure`). Успешный путь `image` (`done` / `needs_check`, файл в `results/…`, `s3_object` с `bucket_role = results`) сквозной проверкой не покрыт; в `scan-gateway` проверяется только синтетическим ответом. Нужны образцы успешных ответов Content AI. | scan-gateway: п. 3 таблицы предложений (O-1, R-10, design #32); docs: O-15 (задача 4 фазы 2), design #31 | срез 3 (OQ-13/14, нормализатор); fixtures | **частично закрыт человеком 04.10.2026:** добавлены реальные образцы — `aep_3.json` (успех: баланс 42 строки + ОФР 18 строк), `aep_3_ofr.json` (второй ответ ИС по тому же файлу, `INVALID_DOCUMENT`), `aep_6.json` (успех: баланс 122 строки + ОФР 49 строк); PR в `arm-ka-fixtures`. Успешный путь `image` на реальных ответах — Срез 3 (нормализатор); транспорт `scan-gateway` проверяется на них в Срезе 0 (склейка нескольких json, `NOT_IMPLEMENTED`) |
| OQ-77 | §11 `05-domain-rules.md` описывает `data_ocr[]` (список), а в фикстурах `data_ocr` — объект с полями `label/organization/inn/units/date/table_structure`. Какой формат фактический на стенде ИС (связано с OQ-13, OQ-14)? | `05-domain-rules.md` §11; scan-gateway: п. 4 таблицы предложений (O-1, T3.2) | срез 3 (нормализатор) | **закрыт человеком 04.10.2026 (по `aep_3.json`, `aep_6.json`):** `data_ocr` — **объект**, один на json-файл (`label`, `organization`, `inn`, `units`, `date` — `{form_name, confidence}`; `table_structure` — **список таблиц** `{label, rows[]}`, одна таблица = один документ БС/ОФР, в одном json их может быть несколько). Успех — `error = {code: "", message: ""}` (не `null`!), ошибка — непустой `code` и пустой `table_structure`. §11 `05-domain-rules.md` исправлен. **Уточнение человека 04.10.2026:** разбиение одной формы на несколько таблиц (сегменты) в образцах не встречалось; наблюдается только несколько таблиц **разных** форм в одном json (баланс + ОФР). Нештатный случай (повтор `label`) — `NEEDS_CHECK`, правило уточнить в Срезе 3 по реальным данным |
| 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-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 доступных только для чтения) |
| OQ-85 | **Возможен ли JetStream на платформе** (серверный дедуп по `Nats-Msg-Id`, durable consumer'ы, redelivery вместо ack и повторной публикации на уровне приложения)? Сейчас NATS без JetStream (ответ платформы 03.10.2026); Срез 0 строит доставку на ack `processing`, повторной публикации ядром и дедупе по `recognition_job.result_msg_id`. Если JetStream подключить можно — меняются только publisher/subscriber (`core/nats.py` в трёх сервисах), outbox/inbox и контракт остаются; исчезают потеря результата при простое ядра и риск повторной обработки. | решение человека 04.10.2026: «узнать у платформы и проверить позже»; `design.md` slice-0-skeleton (D-3, Open Questions п. 4); OQ-72, OQ-73 | инфраструктура NATS (платформа); `core/nats.py`, воркеры сервисов | **вопрос к платформе (поставить напоминание, проверить после ревью Среза 0 / до Среза 3)**: допущение — без JetStream |
Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения;
`DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4.

View File

@ -19,6 +19,9 @@
платформе; ack и повторная публикация — на уровне приложения), MinIO (S3, один бакет `arm-ka`), эмулятор ИС
Сканирование (часть `scan-gateway`, отдаёт `fixtures/ocr/aep_N.json`), три сервиса (профиль `services`, Python 3.11).
`Makefile` с общими целями `up/test/test-integration/lint/check/check-full` (+ `migrate/seed` в ядре) в каждом репо.
Порты, запуск профиля `services` и ручная сквозная проверка обмена `recognition_job` через NATS (сценарии `tabular` →
`NOT_IMPLEMENTED`, `image` → эмулятор ИС → `INVALID_DOCUMENT`, потеря ack → `dlq`) — `docs/dev/README.md`,
разделы «Профиль `services`» и «Проверка окружения».
- Контракт v1.0 — три схемы в `docs/contracts/`: `recognition-request`, `recognition-result-message` (конверты NATS) и
`recognition-result` (файл результата) + `examples/`. Общий пакет `arm-ka-contracts` **не публикуется**: каждый сервис
хранит копию схем в `api/contracts/` и pydantic-модели в `src/contract/`, идентичность с `docs/contracts` проверяется в `make check`

View File

@ -0,0 +1,14 @@
{
"schema_version": "1.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"pipeline": "image",
"file_type": "PDF",
"original_name": "bb_2025_q3.pdf",
"s3": {
"bucket": "arm-ka",
"key": "uploads/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/bb_2025_q3.pdf"
},
"reply_deadline": "2026-10-03T12:45:00Z",
"attempt": 1
}

View File

@ -0,0 +1,14 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"pipeline": "tabular",
"file_type": "XLSX",
"original_name": "osv_60_2025_q1.xlsx",
"s3": {
"bucket": "arm-ka",
"key": "uploads/a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02/3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01/osv_60_2025_q1.xlsx"
},
"reply_deadline": "2026-10-03T12:30:00Z",
"attempt": 1
}

View File

@ -0,0 +1,12 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"status": "error",
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"
},
"documents": []
}

View File

@ -0,0 +1,21 @@
{
"schema_version": "1.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"status": "done",
"service": { "name": "scan-gateway", "version": "0.1.0" },
"result_s3": {
"bucket": "arm-ka",
"key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json"
},
"result_schema_version": "1.0",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"metrics": {
"duration_ms": 18400,
"pages": 2,
"fields_total": 4,
"fields_low_confidence": 0,
"fields_unrecognized": 0,
"warnings": []
}
}

View File

@ -0,0 +1,11 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "error",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet"
}
}

View File

@ -0,0 +1,7 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"status": "processing",
"service": { "name": "tabular-parser", "version": "0.1.0" }
}

View File

@ -0,0 +1,79 @@
{
"schema_version": "1.0",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
"status": "done",
"documents": [
{
"document_type": "OSV",
"locator": "sheet:Лист_1",
"header": {
"organization_name": "ООО Ромашка",
"organization_inn": "7700000000",
"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": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
],
"columns": {
"opening_debit": "C",
"opening_credit": "D",
"turnover_debit": "E",
"turnover_credit": "F",
"closing_debit": "G",
"closing_credit": "H"
},
"rows": [
{
"row_number": 1,
"level": 0,
"parent_row_number": null,
"row_value": "60",
"group_type": "ACCOUNT",
"group_name": null,
"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": "ООО Лютик, ИНН 7711111111",
"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"
}
],
"checks": {
"total_row_matches_root": true,
"balance_identity_violations": [],
"parent_sum_violations": []
}
}
]
}

View File

@ -0,0 +1,50 @@
{
"schema_version": "1.0",
"job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03",
"file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04",
"service": { "name": "scan-gateway", "version": "0.1.0" },
"status": "done",
"raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json",
"documents": [
{
"document_type": "BALANCE",
"locator": "pages:1-2",
"header": {
"form_name": "Бухгалтерский баланс",
"organization_name": "ООО Ромашка",
"organization_name_confidence": 97.5,
"inn": "7700000000",
"inn_confidence": 99,
"report_date_text": "30 сентября 2025 г.",
"report_date": "2025-09-30",
"units": "тыс. руб."
},
"columns": [
{ "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" },
{ "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" }
],
"lines": [
{
"line_code": "1100",
"line_code_confidence": 99,
"line_name": "Итого по разделу I",
"line_name_confidence": 96,
"values": [
{ "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true },
{ "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true }
]
},
{
"line_code": "1600",
"line_code_confidence": 99,
"line_name": "БАЛАНС (актив)",
"line_name_confidence": 98,
"values": [
{ "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true },
{ "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true }
]
}
]
}
]
}

View File

@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-request/v1",
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
"description": "Тело сообщения на subject recognition.tabular.request или recognition.image.request; subject должен соответствовать полю pipeline (tabular → recognition.tabular.request, image → recognition.image.request). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"$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", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
"job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" },
"file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" },
"pipeline": { "enum": ["tabular", "image"], "description": "конвейер распознавания; определяет subject запроса" },
"file_type": { "enum": ["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] },
"original_name": { "type": "string", "minLength": 1, "description": "имя файла, как загрузил пользователь" },
"s3": {
"type": "object",
"description": "исходный файл в роли uploads",
"required": ["bucket", "key"],
"properties": {
"bucket": { "type": "string", "minLength": 1 },
"key": { "type": "string", "minLength": 1 }
}
},
"reply_deadline": { "type": "string", "format": "date-time", "description": "срок ответа, UTC" },
"attempt": { "type": "integer", "minimum": 1, "description": "номер попытки, с 1" }
}
}

View File

@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
"description": "Тело сообщения на subject recognition.result. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$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", "status", "service"],
"properties": {
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
"job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" },
"file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" },
"status": { "enum": ["processing", "done", "needs_check", "error"] },
"service": {
"type": "object",
"required": ["name", "version"],
"properties": {
"name": { "enum": ["tabular-parser", "scan-gateway"] },
"version": { "type": "string" }
}
},
"result_s3": {
"type": "object",
"description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json",
"required": ["bucket", "key"],
"properties": {
"bucket": { "type": "string", "minLength": 1 },
"key": { "type": "string", "minLength": 1 }
}
},
"result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" },
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] },
"message": { "type": "string" }
}
},
"raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" },
"metrics": {
"type": "object",
"properties": {
"duration_ms": { "type": "integer" },
"pages": { "type": "integer" },
"rows": { "type": "integer" },
"fields_total": { "type": "integer" },
"fields_low_confidence": { "type": "integer" },
"fields_unrecognized": { "type": "integer" },
"warnings": { "type": "array", "items": { "type": "string" } }
}
}
},
"allOf": [
{
"if": {
"properties": { "status": { "enum": ["done", "needs_check"] } },
"required": ["status"]
},
"then": { "required": ["result_s3", "result_schema_version"] }
},
{
"if": {
"properties": { "status": { "const": "error" } },
"required": ["status"]
},
"then": { "required": ["error"] }
}
]
}

View File

@ -1,12 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result/v1",
"title": "Результат распознавания файла (tabular-parser / scan-gateway) — ЧЕРНОВИК v1, уточнить в Срезе 2",
"title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0",
"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": { "const": "1.0" },
"schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
"job_id": { "type": "string", "format": "uuid" },
"file_id": { "type": "string", "format": "uuid" },
"service": {
@ -21,7 +22,7 @@
"error": {
"type": ["object", "null"],
"properties": {
"code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] },
"code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] },
"message": { "type": "string" }
}
},
@ -43,6 +44,13 @@
"items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] }
}
},
"if": {
"properties": { "status": { "enum": ["done", "needs_check"] } },
"required": ["status"]
},
"then": {
"properties": { "documents": { "minItems": 1 } }
},
"$defs": {
"money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" },
"osvDocument": {
@ -131,9 +139,9 @@
"properties": {
"form_name": { "type": "string" },
"organization_name": { "type": ["string", "null"] },
"organization_name_confidence": { "type": ["number", "null"] },
"organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"inn": { "type": ["string", "null"] },
"inn_confidence": { "type": ["number", "null"] },
"inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"report_date_text": { "type": ["string", "null"] },
"report_date": { "type": ["string", "null"], "format": "date" },
"units": { "type": ["string", "null"], "examples": ["тыс. руб."] },
@ -163,9 +171,9 @@
"required": ["line_code", "line_name", "values"],
"properties": {
"line_code": { "type": "string" },
"line_code_confidence": { "type": ["number", "null"] },
"line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"line_name": { "type": "string" },
"line_name_confidence": { "type": ["number", "null"] },
"line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"values": {
"type": "array",
"items": {

60
dev/.env.example Normal file
View File

@ -0,0 +1,60 @@
# Локальное окружение разработки. Скопируйте в .env: cp .env.example .env (.env в git не попадает)
# Файл читают docker compose (из этого каталога) и, блоком ниже, сервисы при запуске из venv.
# --- Переменные docker-compose: учётные данные, порты хоста, бакет ---
# Адрес привязки портов хоста: 127.0.0.1 — только эта машина; 0.0.0.0 — доступ с других машин (пароли по умолчанию dev)
BIND_ADDR=127.0.0.1
# PostgreSQL 16 (схема dkb создаётся миграциями arm-core, не compose)
POSTGRES_DB=armka
POSTGRES_USER=armka
POSTGRES_PASSWORD=armka
POSTGRES_PORT=5432
# NATS 2.10 без JetStream: клиентский порт и порт мониторинга (/varz, /healthz)
NATS_PORT=4222
NATS_MONITOR_PORT=8222
# MinIO: учётная запись администратора, порты API и консоли, единственный бакет
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
MINIO_BUCKET=arm-ka
# Порты хоста сервисов профиля `services` (docker compose --profile services ...). Внутри контейнера каждый сервис
# слушает 8000; эти значения меняют только порт на хосте. Настройки сервисов в контейнерах compose задаёт сам
# (docker-compose.yml, x-service-env), блок ниже на них не влияет.
ARM_CORE_PORT=8000
TABULAR_PARSER_PORT=8001
SCAN_GATEWAY_PORT=8002
# --- Настройки сервисов при запуске из venv против compose по localhost (формат шаблона команды) ---
# Значения согласованы с блоком выше: при смене порта/пароля/бакета меняйте обе части.
# Если порт хоста изменён, поправьте и порты ниже.
DEBUG=true
# PostgreSQL (драйвер asyncpg)
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka
# MinIO: адрес без схемы (host:port) — так ждёт miniopy-async
OPENBAO__MINIO__MINIO_URL=localhost:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin
OPENBAO__MINIO__MINIO_BUCKET=arm-ka
# NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys
OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
# SMB — заглушки (в Срезе 0 не используется); блок необязателен для arm-core и scan-gateway (режим local).
# Блок OPENBAO__DATABASE__* знает только arm-core: scan-gateway и tabular-parser неизвестные ключи OPENBAO__* отвергают
# (extra_forbidden) — для scan-gateway удалите блок DATABASE, для tabular-parser удалите блоки DATABASE и SMB (см. dev/README.md)
OPENBAO__SMB__SMB_HOST=localhost
OPENBAO__SMB__SMB_PATH=share
OPENBAO__SMB__SMB_LOGIN=user
OPENBAO__SMB__SMB_PASSWORD=password
# Включает подключение arm-core к NATS (без него /healthcheck показывает nats=off)
NATS_ENABLED=true

75
dev/Makefile.common Normal file
View File

@ -0,0 +1,75 @@
# Эталонный фрагмент общих целей разработки для сервисов АРМ КА (arm-core, tabular-parser, scan-gateway).
# Описание целей и порядок проверок перед PR — dev/checks.md. Источник эталона: Makefile scan-gateway.
#
# Подключение (два способа, смысл одинаков):
# 1. include (в workspace и в worktree .wt/<срез>/ docs лежит рядом с сервисом):
# include ../docs/dev/Makefile.common
# Ставьте include ПОСЛЕ первой собственной цели сервиса или задайте `.DEFAULT_GOAL := check`,
# иначе целью по умолчанию станет `venv` (первая цель этого файла).
# 2. копирование: скопируйте содержимое в Makefile сервиса, не меняя рецепты общих целей.
#
# Запуск — из корня репозитория сервиса; рецепты сами переходят в api/ и используют окружение api/.venv.
# Все переменные заданы через `?=`: сервис переопределяет их до include (или в командной строке: make up COMPOSE_PROJECT=x).
#
# VENV каталог окружения относительно корня сервиса (api/.venv)
# BIN каталог исполняемых файлов окружения (абсолютный путь, строится от $(CURDIR))
# MYPY_PATHS что проверяет mypy (src; у arm-core: src scripts)
# COMPOSE_FILE compose-файл инфраструктуры (../docs/dev/docker-compose.yml)
# COMPOSE_PROJECT имя compose-проекта; общее для всех сервисов workspace (arm-ka), поэтому целей down/stop/restart нет
# IMAGE тег docker-образа (по умолчанию — имя каталога сервиса)
# CHECK_FULL_STEPS цели `check-full` по порядку; сервис дописывает свои (arm-core: check dbml-check check-infra
# test-integration docker-build web-check). Собственные цели сервиса (migrate, seed, web-check ...)
# определяются в его Makefile, не здесь.
#
# Запускайте без `make -j`: шаги check-full вызываются последовательно через $(MAKE) и при -j, но `check: lint test` —
# пререквизиты, и при -j `lint` и `test` идут параллельно, поэтому остановка на первой упавшей проверке не гарантируется.
VENV ?= api/.venv
BIN ?= $(CURDIR)/$(VENV)/bin
MYPY_PATHS ?= src
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
COMPOSE_PROJECT ?= arm-ka
IMAGE ?= $(notdir $(CURDIR))
CHECK_FULL_STEPS ?= check test-integration docker-build
# Перевод строки для $(foreach ...) в рецепте: каждый шаг становится отдельной строкой рецепта (две пустые строки обязательны)
define newline
endef
.PHONY: venv lint test test-integration check docker-build check-full up
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
venv:
cd api && uv venv --python 3.11 --allow-existing .venv
cd api && grep -v '^raisa_' requirements.txt | uv pip install --python .venv/bin/python -r -
cd api && grep -v '^-r' requirements-dev.txt | uv pip install --python .venv/bin/python -r -
lint:
cd api && $(BIN)/ruff check .
cd api && $(BIN)/ruff format --check .
cd api && $(BIN)/mypy $(MYPY_PATHS)
test:
cd api && $(BIN)/pytest -m "not integration"
test-integration:
cd api && $(BIN)/pytest -m integration
check: lint test
# Образ собирается из корня репозитория; тесты в образе не запускаются
docker-build:
docker build -t $(IMAGE) .
# Полная проверка перед PR. Шаги вызываются последовательно через $(MAKE), а не как пререквизиты:
# порядок и остановка на первой ошибке гарантированы и при make -j.
check-full:
$(foreach step,$(CHECK_FULL_STEPS),$(MAKE) $(step)$(newline))
# Общий compose-проект workspace. --wait — только со списком долгоживущих сервисов: одноразовый minio-init
# завершается кодом 0, и compose считает это сбоем. Бакет arm-ka создаётся отдельным идемпотентным шагом.
up:
docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) up -d --wait postgres nats minio
docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) run --rm minio-init

430
dev/README.md Normal file
View File

@ -0,0 +1,430 @@
# Окружение разработки АРМ КА (`docs/dev/`)
Локальная инфраструктура workspace одной командой: PostgreSQL 16, NATS 2.10 без JetStream и MinIO с бакетом `arm-ka`.
Профиль `services` добавляет к ней сервисы системы, собранные из соседних репозиториев workspace: `arm-core`,
`tabular-parser`, `scan-gateway` и эмулятор ИС Сканирование `scan-emulator` (см. «Профиль `services`»). Без профиля
поднимается только инфраструктура, а сервисы можно запускать из venv (см. «Запуск сервисов из venv»).
## Предварительные требования
- Docker и compose v2 (проверено на docker 28 / compose 2.34).
- `uv` и Python 3.11 — для запуска сервисов из venv (версия Python — как на платформе).
- Свободные порты хоста (по умолчанию доступны только с `127.0.0.1`, см. `BIND_ADDR`): 5432 (PostgreSQL), 4222 и 8222 (NATS и его мониторинг), 9000 и 9001 (MinIO API и консоль);
для профиля `services` ещё 8000 (`arm-core`), 8001 (`tabular-parser`), 8002 (`scan-gateway`).
Занятые порты меняются в `.env` (см. «Типичные проблемы»).
- Для профиля `services`: соседние каталоги `../../arm-core`, `../../tabular-parser`, `../../scan-gateway` и
`../../fixtures` относительно `docs/dev/` (в workspace и в зеркальном worktree `.wt/<срез>/` это одни и те же соседи
`docs`). Сборка образов ставит зависимости из PyPI (строки `raisa_*` отфильтрованы, Nexus не нужен).
## Запуск инфраструктуры
Команды — из каталога `docs/dev/`:
```bash
cp .env.example .env # .env в git не попадает
docker compose up -d # вернётся сразу; healthy — примерно через 6 с
```
Ожидание готовности (для скриптов и `Makefile`):
```bash
docker compose up -d && docker compose up -d --wait postgres nats minio
```
Голый `docker compose up -d --wait` **возвращает код 1**: одноразовый `minio-init` (создаёт бакет и завершается с
кодом 0) compose 2.34 считает сбоем (`container arm-ka-minio-init-1 exited (0)`). Поэтому `--wait` нужно давать со
списком долгоживущих сервисов — как выше.
Проверка:
```bash
docker compose ps # postgres, nats, minio — Up (healthy); minio-init — Exited (0)
docker compose exec postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select version()"'
curl -s localhost:8222/healthz # {"status":"ok"}
curl -s localhost:8222/varz | grep -A1 jetstream # "jetstream": {} — JetStream не включён
docker compose run --rm --entrypoint sh minio-init -c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc ls local'
# [...] 0B arm-ka/
```
Консоль MinIO — <http://localhost:9001> (по умолчанию `minioadmin` / `minioadmin`); S3 API — `localhost:9000`.
Схема БД (`dkb`) создаётся миграциями `arm-core`, не compose.
## Профиль `services`
Сервисы системы в контейнерах против той же инфраструктуры. Образы собираются из `Dockerfile` в корне соседних
репозиториев (`../../arm-core`, `../../tabular-parser`, `../../scan-gateway`) и получают теги `arm-core:dev`,
`tabular-parser:dev`, `scan-gateway:dev`. Настройки сервисов задаёт сам compose (переменные `OPENBAO__*` шаблона с
адресами внутри сети compose — `postgres:5432`, `nats:4222`, `minio:9000`, `DEBUG=true`); второй блок `.env` и
`api/.env` сервисов на контейнеры не влияют.
Команды — из каталога `docs/dev/`:
```bash
cp .env.example .env # если ещё не сделано
docker compose --profile services up -d --build # сборка образов и запуск; первая сборка — около минуты
docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator # ждать healthy
```
| Сервис | Порт хоста (`.env`) | Порт в контейнере | Что делает |
|---|---|---|---|
| `arm-core` | 8000 (`ARM_CORE_PORT`) | 8000 | ядро: API, outbox/inbox `recognition_job`, подписка `recognition.result` |
| `tabular-parser` | 8001 (`TABULAR_PARSER_PORT`) | 8000 | подписка `recognition.tabular.request` (queue group `tabular-parser`) |
| `scan-gateway` | 8002 (`SCAN_GATEWAY_PORT`) | 8000 | подписка `recognition.image.request` (queue group `scan-gateway`), горячая папка — том `hotfolder` |
| `scan-emulator` | — | — | эмулятор ИС Сканирование: следит за `Input/` тома `hotfolder`, ответы — из `../../fixtures/ocr` (только чтение) |
| `arm-core-migrate` | — | — | одноразовый: `alembic upgrade head` и `python -m scripts.seed --dev`, затем `arm-core` стартует |
Все три сервиса внутри контейнера слушают **8000** (значение по умолчанию `SERVER__PORT`); различаются только порты
хоста. Сервис, запущенный из venv, по умолчанию тоже слушает 8000 на хосте — он конфликтует с контейнером `arm-core`
(остановите контейнер или задайте `SERVER__PORT` в `api/.env`).
Порядок старта задан `depends_on`: `arm-core-migrate` ждёт healthy `postgres`; `arm-core` — `postgres`, `nats`, `minio`,
успешного завершения `minio-init` и `arm-core-migrate`; `tabular-parser` и `scan-gateway` — `nats`, `minio` и
`minio-init`. Одноразовые `minio-init` и `arm-core-migrate` выполняются при каждом `up` профиля (миграции и seed
идемпотентны: повторный seed пишет `0 / 0 / N` — без изменений) и остаются в `ps -a` как `Exited (0)`.
Проверка (фактический вывод на чистых томах, 2026-10-04, сокращён):
```bash
docker compose --profile services ps -a
# arm-core Up (healthy) 127.0.0.1:8000->8000/tcp
# arm-core-migrate Exited (0)
# minio-init Exited (0)
# scan-emulator Up 8000/tcp (healthcheck нет: процесс без HTTP)
# scan-gateway Up (healthy) 127.0.0.1:8002->8000/tcp
# tabular-parser Up (healthy) 127.0.0.1:8001->8000/tcp
curl -s localhost:8000/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8001/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8002/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","hotfolder":"ok"}
docker compose --profile services logs arm-core-migrate # Running upgrade -> 0001_dbml_v5_13 …; Seed применён …
docker compose --profile services logs scan-emulator # … корень /data/hotfolder …; Фикстуры: /fixtures/ocr, фикстур: 2
```
`/healthcheck` всех трёх сервисов **всегда отвечает 200**, поэтому healthcheck контейнеров читает поля ответа:
`nats = ok` (и `hotfolder = ok` у `scan-gateway`). Статус `healthy` означает, что сервис подключён к NATS.
Dev-токен `arm-core` (в compose включён dev-эмитент, `AUTH_DEV_ISSUER=true`, `DEBUG=true`; пользователи — из
`seed --dev`, см. `../../arm-core/README.md`):
```bash
curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' -d '{"email":"ka_rf@armka.local"}'
curl -s localhost:8000/api/v1/me -H "Authorization: Bearer <JWT>" # email, роли KA_RF, права
```
Образ `arm-core` содержит только бэкенд: фронт (`web/`) в `Dockerfile` не собирается. `GET /` отвечает **307** с
редиректом на `//`, а тот — 404 (`curl -L` показывает 404 на `http://127.0.0.1:8000//`; редирект на `//` — особенность
`arm-core`, вынесена в его follow-up). UI — `npm run dev` из `../../arm-core/web` (см. README `arm-core`).
**Ожидание готовности и коды возврата `--wait`** (compose 2.34, проверено 2026-10-04):
| Команда | Код | Комментарий |
|---|---|---|
| `docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator` | 0 | рекомендуемая форма |
| `docker compose --profile services up -d --wait scan-gateway scan-emulator` | 0 | и на чистых томах, и на работающем окружении: `minio-init` поднимается как зависимость `scan-gateway` (`service_completed_successfully`) и его выход с кодом 0 сбоем не считается |
| `docker compose --profile services up -d --wait` (без списка) | 0 | все одноразовые контейнеры профиля — зависимости с `service_completed_successfully` |
| `docker compose up -d --wait` (без профиля и списка) | 1 | `container arm-ka-minio-init-1 exited (0)`: без профиля у `minio-init` нет зависимых сервисов |
Для скриптов указывайте долгоживущие сервисы явно — форма не зависит от состава профиля.
**Пересборка после изменения кода сервиса:** `docker compose --profile services up -d --build` — пересобирает образы
и пересоздаёт все контейнеры, чей образ сменился. Образ `arm-core:dev` собирает только сервис `arm-core`, образ
`scan-gateway:dev` — только `scan-gateway`; `arm-core-migrate` и `scan-emulator` используют готовый тег
(`pull_policy: never`): две сборки одного тега давали разные ID образа, и каждый следующий `up` пересоздавал контейнеры.
Поэтому `arm-core-migrate` или `scan-emulator` в одиночку (`up -d scan-emulator`) на машине без образа не стартуют —
сначала соберите его (`docker compose --profile services build scan-gateway`) или поднимайте профиль целиком.
Форма `up -d --build <сервис>` пересоздаёт только сервис и его зависимости (проверено 2026-10-04 на образе,
пересобранном с `--no-cache`):
| Команда | Что пересоздано | Что осталось на старом образе |
|---|---|---|
| `up -d --build arm-core` | `arm-core` и `arm-core-migrate` (он — зависимость `arm-core`, миграции нового кода выполняются) | — |
| `up -d --build scan-gateway` | только `scan-gateway` | **`scan-emulator`** (не зависимость шлюза) — до следующего `docker compose --profile services up -d`, который его пересоздаёт |
После правок кода `scan-gateway`, затрагивающих эмулятор, пересобирайте профиль целиком.
**Остановка и сброс профиля** — только с `--profile services`: без него compose не видит контейнеры профиля
(см. «Сброс окружения»).
## Проверка окружения (сквозной сценарий)
Ручная проверка обмена `recognition_job` через NATS на поднятом профиле `services`: ядро публикует задание, сервис
отвечает ack и результатом, ядро меняет статус. UI и API загрузки файлов в Срезе 0 нет, поэтому файл кладётся в MinIO
через `mc`, а строки `legal_entity` / `s3_object` / `stored_file` / `recognition_job` вставляются через `psql`. Проверено 2026-10-04
(все три сценария ниже — фактический вывод, время UTC).
Помощники (bash/zsh, из `docs/dev/`; `uuidgen` есть в macOS и util-linux):
```bash
dpsql() { docker compose exec -T postgres sh -c 'psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"; }
dmc() { docker compose run --rm -v "$PWD/../../fixtures:/fixtures:ro" --entrypoint sh minio-init \
-c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc "$@"' sh "$@"; }
new_job() { # new_job <tabular|image> <file_type> <файл в fixtures> — кладёт файл в uploads/ и ставит задание queued
F=$(uuidgen | tr A-Z a-z); JOB=$(uuidgen | tr A-Z a-z); O=$(uuidgen | tr A-Z a-z); N=$(basename "$3")
dmc cp -q "/fixtures/$3" "local/arm-ka/uploads/$F/$JOB/$N" >/dev/null || return 1
dpsql <<SQL
insert into dkb.legal_entity (id, inn, name, valid_from) values (gen_random_uuid(), '7700000001', 'Проверка окружения', now())
on conflict (inn) do nothing;
insert into dkb.s3_object (id, bucket_role, bucket, key, created_at) values ('$O', 'uploads', 'arm-ka', 'uploads/$F/$JOB/$N', now());
insert into dkb.stored_file (id, legal_entity_id, file_type, original_name, s3_object_id, status, uploaded_at)
values ('$F', (select id from dkb.legal_entity where inn = '7700000001'), '$2', '$N', '$O', 'UPLOADED', now());
insert into dkb.recognition_job (id, file_id, pipeline, request_subject, schema_version, status, requested_at)
values ('$JOB', '$F', '$1', 'recognition.$1.request', '1.0', 'queued', now());
SQL
echo "JOB=$JOB"
}
poll() { # poll <секунд> — статус задания $JOB раз в секунду, до финального статуса
for _ in $(seq "$1"); do
s=$(dpsql -At -c "select status || ' ' || coalesce(error_code, '') || ' ' || coalesce(metrics->>'republish_count', '') from dkb.recognition_job where id = '$JOB'")
[ "$s" != "$p" ] && echo "$(date -u +%T) $s" && p=$s
case $s in error*|done*|needs_check*|timeout*|dlq*) p=; return;; esac
sleep 1
done; p=
}
```
Рецепт прогнан дословно на чистой БД (вставка `legal_entity` без конфликта по `inn`) и повторно при уже существующей
строке (`on conflict (inn) do nothing`); оба пути проверены.
Ключ исходника — `uploads/{file_id}/{job_id}/{имя}` (`../context/03-architecture.md`, «Схема ключей S3»). Сообщения на
шине можно смотреть параллельно: `docker run --rm --network arm-ka_default natsio/nats-box:0.17.0 nats sub -s
nats://nats:4222 'recognition.>'` (печатает `Nats-Msg-Id` и тело каждого запроса, ack и результата).
**1. `tabular` → `NOT_IMPLEMENTED`.** Заглушка парсера Среза 0 отвечает ошибкой на любой файл:
```bash
new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 30
# JOB=e585f47b-1b38-44a6-84c5-9432c7e6d194
# 02:12:15 queued
# 02:12:20 error NOT_IMPLEMENTED
dpsql -c "select status, error_code, error_message, published_at::time(3), started_at::time(3), finished_at::time(3) from dkb.recognition_job where id = '$JOB'"
# error | NOT_IMPLEMENTED | разбор XLS/XLSX/XLSB реализуется в Срезе 2 | 02:12:18.98 | 02:12:19.021 | 02:12:19.024
```
`published → processing → error` проходит за десятки миллисекунд (в прогонах 14–51 мс), опросом его не поймать; последовательность видна по
`published_at < started_at < finished_at` (`started_at` пишет ack), в логе `arm-core` («Запрос распознавания
опубликован» → «Сервис подтвердил получение запроса (ack): задание в processing» → «Результат распознавания принят …
error_code=NOT_IMPLEMENTED») и на шине: `recognition.tabular.request` (`Nats-Msg-Id: <job_id>:1`) →
`recognition.result` `ack:<job_id>:1` (`status: processing`) → `result:<job_id>:1` (`status: error`). Публикатор
опрашивает outbox раз в 5 с, поэтому от вставки до публикации проходит до 5 с.
**2. `image` → эмулятор ИС → `INVALID_DOCUMENT`, сырой ответ в `raw/`:**
```bash
new_job image PDF ocr/aep_5.PDF && poll 30
# JOB=46b0c90b-5a1b-4ac0-9e8a-0cdad80d857e
# 02:12:27 queued
# 02:12:29 processing
# 02:12:34 error INVALID_DOCUMENT
dmc ls local/arm-ka/raw/
# [2026-10-04 11:23:58 UTC] 600B STANDARD 0ea16933-4cc1-4b50-bda9-a97089a918c5.json
dmc cat "local/arm-ka/raw/$JOB.json" # {"job_id": "<job_id>", "files": [{"name": "<job_id>.json", "response": {"file_uid": "<job_id>", "error": {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}, "data_ocr": {…}}, "parse_error": null}]}
```
Код `INVALID_DOCUMENT` здесь — **настоящий ответ ИС из фикстуры**: `aep_5.json` и `aep_8_form4.json` — ответы-ошибки
ИС Сканирование («Не удалось определить тип документа.», пустой `table_structure`); у `aep_3` и `aep_6` есть успешные
ответы (сценарий 2а). Эмулятор нашёл набор ответов (в логе `scan-emulator`: `<job_id>_aep_5.PDF ->
/data/hotfolder/Output/<job_id>`; при отсутствии набора сообщение было бы «…: нет эталонного ответа в фикстурах»).
Успешные ответы ИС есть (`aep_3.json` + `aep_3_ofr.json`, `aep_6.json`); шлюз склеивает все `*.json` каталога ответа и
отвечает `NOT_IMPLEMENTED` (нормализация — Срез 3), ошибочные файлы набора — в `metrics.warnings`. Имя файла — `aep_5.PDF` (расширение в верхнем регистре; на macOS `aep_5.pdf` тоже находится, на
Linux — нет).
`processing` виден ~10 с: задержка эмулятора 2 с, затем до `SCAN_POLL_SECONDS + SCAN_SETTLE_SECONDS` (5 + 2 с) после
первого опроса, увидевшего набор.
**2а. `image` → успешный ответ ИС → `NOT_IMPLEMENTED`:**
```bash
new_job image PDF ocr/aep_3.pdf && poll 60
# 11:22:07 queued
# 11:22:08 processing
# 11:22:18 error NOT_IMPLEMENTED
dpsql -c "select status, error_code, metrics from dkb.recognition_job where id = '$JOB'"
# error | NOT_IMPLEMENTED | {"warnings": ["<job_id>_ofr.json: INVALID_DOCUMENT: Не удалось определить тип документа."]}
new_job image PDF ocr/aep_6.pdf && poll 60
# error NOT_IMPLEMENTED, metrics пусто
```
**3. Сервис недоступен → три повторные публикации → `dlq` / `NO_ACK`:**
```bash
docker compose stop tabular-parser # имя сервиса указано явно — профиль не нужен
new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 180
# 02:12:43 queued
# 02:12:44 published
# 02:13:14 published 1 ← republish_count: повтор через ACK_TIMEOUT_SECONDS = 30 с, тот же Nats-Msg-Id <job_id>:1
# 02:13:45 published 2
# 02:14:14 published 3
# 02:14:44 dlq NO_ACK 3 ← ещё через 30 с после третьего повтора (OUTBOX_MAX_REPUBLISH = 3)
dpsql -c "select status, error_code, error_message, metrics, request_msg_id from dkb.recognition_job where id = '$JOB'"
# dlq | NO_ACK | Сервис распознавания не подтвердил получение запроса (ack) после 3 повторных публикаций | {"republish_count": 3} | <job_id>:1
docker compose --profile services up -d --wait tabular-parser # вернуть сервис; rc=0, Healthy
```
От первой публикации до `dlq` — ~120 с (4 × 30 с). `published_at` при каждом повторе перезаписывается (время последней
публикации), `attempt` остаётся 1, `started_at` и `service_name` пусты. Задание в `dlq` после возврата сервиса само не
перезапускается (replay — вне Среза 0).
Что сквозная проверка **не** покрывает (Срез 0): успешные результаты (`done`/`needs_check`, регистрация файла
`results/…` в `s3_object`) — заглушка парсера и нормализатор шлюза Среза 0 (`NOT_IMPLEMENTED`) дают только ошибки,
хотя фикстуры ИС (`aep_3`, `aep_6`) успешные ответы содержат; `stored_file.status` остаётся
`UPLOADED`, `current_recognition_job_id` не заполняется (маппинг результата — `NoopMapper`, Срез 2); у ошибок
`scan-gateway` в `recognition_job.metrics` только `warnings` — по строке `<имя файла>: <код>: <сообщение>` на ошибочный
файл ответа ИС, если такие есть (`tabular-parser` присылает `duration_ms`). Тестовые строки и объекты остаются в БД и
MinIO; полная очистка — `docker compose --profile services down -v` (удаляет и общие тома `postgres-data`/`minio-data`
всех worktree: проект compose `arm-ka` общий).
## Переменные `.env`
Файл читают `docker compose` (из этого каталога) и, вторым блоком, сервисы при запуске из venv. Значения по умолчанию —
только для dev.
Блок compose:
| Переменная | По умолчанию | Для чего |
|---|---|---|
| `BIND_ADDR` | `127.0.0.1` | адрес привязки всех портов хоста; `0.0.0.0` — доступ с других машин (небезопасно, см. «Примечания») |
| `POSTGRES_DB` | `armka` | имя БД |
| `POSTGRES_USER` | `armka` | пользователь БД |
| `POSTGRES_PASSWORD` | `armka` | пароль БД |
| `POSTGRES_PORT` | `5432` | порт PostgreSQL на хосте |
| `NATS_PORT` | `4222` | клиентский порт NATS на хосте |
| `NATS_MONITOR_PORT` | `8222` | порт мониторинга NATS (`/varz`, `/healthz`) |
| `MINIO_ROOT_USER` | `minioadmin` | администратор MinIO |
| `MINIO_ROOT_PASSWORD` | `minioadmin` | пароль администратора MinIO |
| `MINIO_API_PORT` | `9000` | порт S3 API на хосте |
| `MINIO_CONSOLE_PORT` | `9001` | порт консоли MinIO на хосте |
| `MINIO_BUCKET` | `arm-ka` | единственный бакет, создаётся `minio-init` |
| `ARM_CORE_PORT` | `8000` | порт хоста `arm-core` (профиль `services`; в контейнере 8000) |
| `TABULAR_PARSER_PORT` | `8001` | порт хоста `tabular-parser` (профиль `services`; в контейнере 8000) |
| `SCAN_GATEWAY_PORT` | `8002` | порт хоста `scan-gateway` (профиль `services`; в контейнере 8000) |
Блок сервисов (запуск из venv против compose по `localhost`, формат шаблона команды):
| Переменная | По умолчанию | Для чего |
|---|---|---|
| `DEBUG` | `true` | dev-режим: пакеты `raisa_*` импортируются условно |
| `OPENBAO__DATABASE__DATABASE_URL` | `postgresql+asyncpg://armka:armka@localhost:5432/armka` | подключение к PostgreSQL (asyncpg) |
| `OPENBAO__MINIO__MINIO_URL` | `localhost:9000` | адрес MinIO без схемы (`host:port`, так ждёт `miniopy-async`) |
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | `minioadmin` | логин MinIO |
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | `minioadmin` | пароль MinIO |
| `OPENBAO__MINIO__MINIO_BUCKET` | `arm-ka` | бакет |
| `OPENBAO__NATS__NATS_URL` | `nats://localhost:4222` | адрес NATS |
| `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без nkeys |
| `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; пусто — подключение без nkeys |
| `OPENBAO__SMB__SMB_HOST` | `localhost` | заглушка SMB (в Срезе 0 не используется); блок необязателен для `arm-core` и для `scan-gateway` в режиме `SCAN_HOTFOLDER_MODE=local` (по умолчанию); `tabular-parser` его не знает и отвергнет (см. ниже) |
| `OPENBAO__SMB__SMB_PATH` | `share` | заглушка SMB |
| `OPENBAO__SMB__SMB_LOGIN` | `user` | заглушка SMB |
| `OPENBAO__SMB__SMB_PASSWORD` | `password` | заглушка SMB |
| `NATS_ENABLED` | `true` | включает подключение arm-core к NATS; без него `/healthcheck` показывает `nats=off` |
Два блока описывают одни и те же ресурсы: **при смене порта, пароля, пользователя или бакета меняйте обе части.**
## Сброс окружения
```bash
docker compose --profile services down # останавливает все контейнеры проекта; тома сохраняются
docker compose --profile services down -v # полный сброс: тома postgres-data, minio-data и hotfolder (БД, бакет, горячая папка — с нуля)
```
Без `--profile services` команда `down` (с `-v` или без) **не трогает** контейнеры профиля: проверено — после
`docker compose down -v` контейнеры `arm-core`/`tabular-parser`/`scan-gateway`/`scan-emulator` продолжают работать,
том `hotfolder` и сеть `arm-ka_default` остаются (`Network arm-ka_default Resource is still in use`). Если профиль не
поднимался, `docker compose down [-v]` достаточно.
Имя проекта compose фиксировано (`name: arm-ka`), поэтому тома общие для любого worktree (`.wt/<срез>/docs/dev`):
сброс из одного worktree затрагивает все.
## Запуск сервисов из venv без Nexus (черновик)
> Черновик: запуск сервисов из venv в Срезе 0 не проверялся (сквозная проверка шла на профиле `services`); шаги ниже
> не прогонялись целиком. Сценарий spec `dev-environment` остаётся неподтверждённым — вопрос
> [OQ-84](../context/07-open-questions.md), решение design #43. `make venv` и `make test-integration` есть во всех трёх
> сервисах.
Пакеты `raisa_*` из Nexus банка вне банка недоступны (D-10). Поэтому `make venv` ставит `requirements.txt` без строк
`raisa_*` (фильтр `grep -v '^raisa_'`), код импортирует `raisa_*` условно (при `DEBUG=true`), а npm-пакеты `raisa-*`
в `web/package.json` вынесены в `optionalDependencies`.
Шаги для сервиса (пути — относительно каталога его репозитория; в workspace `docs/` лежит рядом, `../docs/dev/`):
```bash
# 1. поднять инфраструктуру (из docs/dev/, см. выше)
# 2. из корня репозитория сервиса:
cp ../docs/dev/.env.example api/.env
make venv # api/.venv: Python 3.11 + зависимости без raisa_*
cd api && .venv/bin/python run_server.py # сервис подключается к PostgreSQL/NATS/MinIO по localhost
# 3. в другом терминале:
curl -s localhost:<порт сервиса>/healthcheck
```
Из venv каждый сервис слушает порт из своих настроек (`SERVER__PORT`, по умолчанию **8000** у всех трёх) — порты
хоста 8001/8002 относятся только к контейнерам профиля `services` (см. таблицу портов выше). Два сервиса из venv
одновременно или сервис из venv рядом с профилем `services` требуют разных `SERVER__PORT`. Для `arm-core` в ответе
`/healthcheck` ожидаются `version` и `nats = ok` — при `NATS_ENABLED=true` (задан в `.env.example`; без него
`nats = off`). Поля `/healthcheck` в контейнерах: `tabular-parser` — `uptime`, `version`, `nats`; `scan-gateway` — ещё
`hotfolder` (шлюзу нужен `SCAN_HOTFOLDER_PATH`). Набор переменных сервисов для запуска из venv не проверялся (см. черновик выше и OQ-84). По чтению конфигов: `scan-gateway` и `tabular-parser` отвергают неизвестные ключи `OPENBAO__*`, а поле DATABASE есть только у `arm-core`. Поэтому в `.env` `scan-gateway` нужно удалить строки `OPENBAO__DATABASE__*` (SMB в режиме `local` допустим), а в `.env` `tabular-parser` — `OPENBAO__DATABASE__*` и `OPENBAO__SMB__*`; для MinIO по `http` нужен `OPENBAO__MINIO__MINIO_SECURE=false` (в `.env.example` его нет, по умолчанию `true`).
## Интеграционные тесты сервисов против compose (черновик)
> Черновик: состав целей и порядок проверок описаны в [`checks.md`](checks.md); открытые вопросы — OQ-83 (общий NATS
> у контейнеров и тестов) и OQ-84.
Общий набор целей (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`) есть во всех сервисных репозиториях
(`arm-core`, `tabular-parser`, `scan-gateway`); семантика, состав `check-full`, порядок перед PR и пропуск тестов без
`docs`/`fixtures` описаны в [`checks.md`](checks.md); эталон рецептов общих целей — [`Makefile.common`](Makefile.common). Кратко: `make check` — быстрые проверки без Docker, `make check-full` —
`check` + интеграционные тесты против этого compose + образ (для `arm-core` ещё сверка с DBML, миграции и `web`);
выполняется перед созданием PR.
Интеграционные тесты **требуют поднятой инфраструктуры**: сначала `docker compose up -d && docker compose up -d --wait
postgres nats minio`, затем `make test-integration` (или `make check-full`) в репозитории сервиса. Для `scan-gateway`
эмулятор ИС Сканирование поднимается в процессе интеграционного теста; контейнер `scan-emulator` профиля `services`
тестами не используется. Общее описание проверок — [`checks.md`](checks.md).
> **Не запускайте интеграционные тесты `tabular-parser` и `scan-gateway` при поднятом профиле `services`.** Их
> сквозные тесты (`api/tests/integration/test_roundtrip.py`, у `scan-gateway` ещё `test_nats_minio_integration.py`)
> публикуют запросы в боевые `recognition.tabular.request` / `recognition.image.request`, а экземпляр сервиса из теста
> подписан в ту же queue group (`tabular-parser` / `scan-gateway`), что и контейнер. NATS отдаёт каждое сообщение группы
> одному подписчику — запрос теста может уйти контейнеру, и тест упадёт по таймауту ожидания ack (так прямо сказано в
> docstring теста `scan-gateway`). Риск подтверждён чтением кода тестов, тесты при поднятом профиле не запускались.
> Интеграционные тесты `arm-core` от контейнеров изолированы: временная БД на сессию (рабочая `armka` не используется)
> и уникальный subject результатов на тест. Перед тестами сервисов:
> `docker compose --profile services stop tabular-parser scan-gateway scan-emulator`; проверить, что чужих подписчиков
> нет: `curl -s 'http://127.0.0.1:8222/subsz?subs=1' | grep -o '"subject": *"recognition[^"]*"'` — строк
> `recognition.tabular.request` / `recognition.image.request` быть не должно (`recognition.result` — подписка контейнера
> `arm-core`, тестам `arm-core` не мешает).
## Проверка контракта
Из корня репозитория `docs`: `python scripts/validate_contracts.py` — валидирует примеры в
[`contracts/examples/`](../contracts/examples/) тремя схемами из [`contracts/`](../contracts/) (3 схемы, 8 примеров;
нужен только пакет `jsonschema`: формат `date-time` скрипт проверяет собственной функцией, поэтому `rfc3339-validator`
не требуется). Или `make venv && make check` (`make venv` создаёт `.venv` и ставит `jsonschema`; `check` использует этот
`.venv` и ещё запускает `openspec validate`), см. [`checks.md`](checks.md).
## Примечания
- **Образы MinIO неофициальные.** Официальные `minio/minio` и `minio/mc` недоступны (Docker Hub — denied, на quay.io нет
нужных тегов), поэтому в compose стоят `alpine/minio:RELEASE.2025-10-15T17-29-55Z` и
`bitnamilegacy/minio-client:2025.7.21`, а сервис `minio` запущен с `user: "0:0"` (иначе образ не пишет в свежий том) —
только для dev. Решения: [`design.md`](../openspec/changes/slice-0-skeleton/design.md), раздел
«Decisions made without discussion», решения №16–17. Замена на официальные образы — две строки `image:` в
`docker-compose.yml` (и удаление `user: "0:0"`).
- **Безопасность.** По умолчанию порты публикуются только на `127.0.0.1` (`BIND_ADDR`), снаружи машины они недоступны.
Чтобы открыть их для других машин, задайте в `.env` `BIND_ADDR=0.0.0.0` — но пароли по умолчанию (`armka`,
`minioadmin`) годятся только для dev, поэтому сначала смените их (в обоих блоках `.env`) и не делайте этого в
недоверенной сети.
## Типичные проблемы
- **Порт занят** (`address already in use`): измените `*_PORT` в `.env` и одновременно соответствующий порт в
`OPENBAO__*` (`DATABASE_URL`, `MINIO_URL`, `NATS_URL`); перезапустите `docker compose up -d`. Для портов сервисов
(`ARM_CORE_PORT`, `TABULAR_PARSER_PORT`, `SCAN_GATEWAY_PORT`) достаточно `.env`.
- **Сервис профиля не становится healthy:** `docker compose --profile services logs <сервис>`. `nats = down` —
NATS недоступен; `hotfolder = down` у `scan-gateway` — каталог `SCAN_HOTFOLDER_PATH` (том `hotfolder`) недоступен. `arm-core` не стартует, пока
`arm-core-migrate` не завершился с кодом 0: `docker compose --profile services logs arm-core-migrate`.
- **`No such image: arm-core:dev` / `scan-gateway:dev`** при запуске `arm-core-migrate` или `scan-emulator` по
отдельности: образ ещё не собран — `docker compose --profile services build` (см. «Профиль `services`»).
- **`minio-init` завис или завершился с ошибкой:** `docker compose logs minio-init`; чаще всего `minio` не стал healthy —
`docker compose logs minio`. Повторный `docker compose up -d` идемпотентен.
- **Остаточные тома от прошлых запусков** (старый пароль БД, бакет не тот): `docker compose --profile services down -v`
и запуск заново (без `--profile services` контейнеры профиля и том `hotfolder` остаются, см. «Сброс окружения»).
- **`up -d --wait` возвращает код 1:** см. раздел «Запуск инфраструктуры» — указывайте сервисы явно.

181
dev/checks.md Normal file
View File

@ -0,0 +1,181 @@
# Проверки качества: цели `make` и порядок перед PR
Конфигураций CI в репозиториях нет (в Gitea CI не используется, пайплайн платформы банка вне зоны проекта), поэтому
проверки выполняются локально одной командой перед каждым PR. Требования — spec `quality-checks`
(`openspec/changes/slice-0-skeleton/specs/quality-checks/spec.md`), решение D-8.
Все цели запускаются **из корня репозитория сервиса** (`arm-core`, `tabular-parser`, `scan-gateway`); рецепты сами
переходят в `api/` и используют окружение `api/.venv`. Инфраструктура compose — `docs/dev/docker-compose.yml`
(см. [`README.md`](README.md)); в workspace и в worktree `.wt/<срез>/` она лежит рядом как `../docs/dev/`.
## Общие цели
Одинаковый набор целей во всех трёх сервисных репозиториях; семантика, описанная ниже, — эталонная (реализации
расходятся в деталях, см. «Известные расхождения реализаций»). Репозиторий вправе добавлять свои цели (см. ниже), но
не меняет смысл общих.
| Цель | Что делает | Нужен Docker / compose |
|---|---|---|
| `make venv` | `api/.venv` на Python 3.11 через `uv`; ставит `requirements.txt` без строк `raisa_*` (пакеты Nexus банка вне банка недоступны, D-10) и `requirements-dev.txt` | нет |
| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` (в `arm-core` — `mypy src scripts`) | нет |
| `make test` | `pytest -m "not integration"`: юнит-тесты; **включает тест идентичности вендоренного контракта** `api/contracts/` с `../docs/contracts` | нет |
| `make test-integration` | `pytest -m integration` против инфраструктуры compose (PostgreSQL 16, NATS, MinIO; бакет `arm-ka` должен существовать, его создаёт `make up`) | да, `make up` |
| `make check` | `make lint` затем `make test`; останавливается на первой упавшей проверке (только при последовательном запуске: `lint` и `test` — пререквизиты, при `make -j` они идут параллельно) | нет |
| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker |
| `make up` | поднимает инфраструктуру из `../docs/dev/docker-compose.yml`; постоянные сервисы `postgres`, `nats`, `minio` | Docker |
Сводка шагов `make check`: `ruff check .` → `ruff format --check .` → `mypy src` (в `arm-core` — `mypy src scripts`) →
`pytest -m "not integration"`.
Любой ненулевой код останавливает цель на этом шаге, следующие не выполняются. Запускайте без `-j`: при параллельной сборке шаги идут одновременно. `make check` не требует Docker,
PostgreSQL, NATS и сети.
### `make up`
Поднимает compose-проект `arm-ka` (имя зафиксировано в compose, поэтому тома общие для любого worktree):
```bash
docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka up -d --wait postgres nats minio
docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm minio-init # бакет arm-ka (идемпотентно)
```
Почему так: голый `docker compose up -d --wait` возвращает код 1 (одноразовый `minio-init` завершается кодом 0, compose
считает это сбоем), поэтому `--wait` даётся со списком долгоживущих сервисов, а бакет создаётся отдельным шагом. Без
бакета интеграционные тесты на свежем томе падают на `NoSuchBucket`. Целей `down`/`stop`/`restart` нет намеренно: проект
`arm-ka` общий для всех сервисов; остановка и сброс — вручную (`docker compose --profile services down`, при сбросе данных `down -v` с тем же `--profile services`,
иначе контейнеры сервисов останутся), см. `README.md`.
> Эталонная реализация `make up` — в `scan-gateway`. Реализации в `arm-core` и `tabular-parser` отличаются
> (см. «Известные расхождения реализаций»).
### Состав `make check-full` по репозиториям
| Репозиторий | Шаги по порядку |
|---|---|
| `scan-gateway` | `check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста |
| `tabular-parser` | `check` → `test-integration` → `docker-build` (образ `tabular-parser`) |
| `arm-core` | `check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` |
Для `arm-core` дополнительно:
- `dbml-check` — `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml` (путь от `api/`;
другой — `make dbml-check DBML=<путь относительно api/>`): ORM обязана совпадать с DBML; расхождения печатаются
построчно, код 1.
- Цикл миграций «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой БД, пустой
autogenerate и сверка схемы с `create_all` ORM выполняет `tests/test_migrations.py`; он входит в `test-integration`,
поэтому отдельным шагом в `check-full` не гоняется. Для быстрой проверки одних миграций: `make migration-cycle`.
- `check-infra` — TCP-проверка доступности PostgreSQL и NATS из `api/.env.test`; вместо длинного трейсбека pytest
подсказывает «поднимите инфраструктуру: `make up`».
- `web-check` — `npm install`, `biome check src`, `tsc --noEmit`, `npm run build` в `web/`.
Дополнительные цели репозиториев (не общие): `arm-core` — `migrate`, `seed`, `seed-dev`, `dbml-check`,
`migration-cycle`, `check-infra`, `docker-build`, `web-check`; `scan-gateway` — `help`, `docker-build`, `emulator`;
`tabular-parser` — `docker-build`.
## Эталонный фрагмент `dev/Makefile.common`
Рецепты общих целей вынесены в [`Makefile.common`](Makefile.common) — это эталон (источник — `Makefile` `scan-gateway`),
с которым сервис сверяется `diff`ом или который подключает напрямую:
```make
# Makefile сервиса: свои переменные и цели; цель по умолчанию задана явно, иначе ею станет первая цель include (venv)
.DEFAULT_GOAL := check
MYPY_PATHS := src scripts # arm-core; по умолчанию src
CHECK_FULL_STEPS := check dbml-check check-infra test-integration docker-build web-check # arm-core
include ../docs/dev/Makefile.common
```
Переменные (все `?=`): `VENV`, `BIN`, `MYPY_PATHS`, `COMPOSE_FILE`, `COMPOSE_PROJECT`, `IMAGE` (по умолчанию имя каталога
сервиса; у `scan-gateway` — `scan-gateway:dev`), `CHECK_FULL_STEPS` (по умолчанию `check test-integration docker-build`).
Определяет цели `venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`.
Цели, специфичные для сервиса (`migrate`, `seed`, `dbml-check`, `check-infra`, `web-check`, `emulator`, `help`),
остаются в его `Makefile`.
Проверка «`make check` вызывает одинаковые команды»: `make -n <цель>` в каждом сервисе и в Makefile, подключающем
`Makefile.common` с теми же переменными, даёт одинаковый вывод. Текущее состояние (Срез 0, сервисы не менялись):
`scan-gateway` совпадает с эталоном полностью; `arm-core` и `tabular-parser` — по `venv`, `lint`, `test`,
`test-integration`, `check`, `docker-build` и составу команд `check-full`, но не по `up` и по структуре `check-full`
(пререквизиты вместо вызовов `$(MAKE)`); см. «Известные расхождения реализаций». Сервисы переходят на `include` в
следующих срезах (решение 24 в `design.md`).
## Внешние ресурсы: docs и fixtures опциональны
Проверки сервисов обращаются к соседним репозиториям workspace и **не должны падать**, если их нет: тест
пропускается (`skipped`) с явной причиной, остальные выполняются, `make check` остаётся зелёным.
| Что | Где ищется | Что пропускается без него |
|---|---|---|
| `docs` | `../docs` или каталог из `ARM_KA_DOCS` | тест идентичности контракта `api/contracts/` с `docs/contracts/`; в `arm-core` ещё сверка с DBML в тестах (`dbml-check` как цель `make` требует DBML и падает без него) |
| `fixtures` | `../fixtures` или каталог из `ARM_KA_FIXTURES` (в `arm-core`; в остальных сервисах — см. «Известные расхождения реализаций») | тесты с маркером `fixtures` (реальные данные, внутренний доступ): причина пропуска — «ARM_KA_FIXTURES недоступен …» (в `arm-core`) |
Пример (docs и fixtures лежат не рядом):
```bash
export ARM_KA_DOCS=/path/to/arm-ka-docs
export ARM_KA_FIXTURES=/path/to/arm-ka-fixtures
make check
```
Причины пропусков видно в выводе (`pytest --verbose` в `addopts`: строки `SKIPPED (…)`); полный список —
`cd api && .venv/bin/pytest -m "not integration" -rs`.
Важно: зелёный `make check` с пропусками **не** доказывает идентичность контракта и прохождение на реальных данных. Перед
PR убедитесь, что `docs` и `fixtures` доступны (пропусков по этим причинам нет) либо явно укажите в описании PR, что
пропущено.
> Фактическая поддержка переменных различается по репозиториям — см. «Известные расхождения реализаций».
## Проверки этого репозитория (`docs`)
В репозитории `arm-ka-docs` нет сервисных целей; `Makefile` в его корне содержит только проверки спеки и контракта:
| Цель | Что делает |
|---|---|
| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` — единственной зависимостью скриптов |
| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против трёх схем JSON Schema 2020-12 (3 схемы, 8 примеров); код 1 при любой ошибке |
| `make validate-openspec` | `openspec validate slice-0-skeleton --strict` (другое изменение: `make validate-openspec CHANGE=<id>`) |
| `make check` | обе проверки выше |
Без `make`: `python scripts/validate_contracts.py` (нужен `jsonschema`) и `openspec validate <id> --strict`.
Менять `contracts/**` нужно осознанно: байтовые копии схем лежат в `api/contracts/` каждого сервиса, после правки их
нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт.
## Порядок перед PR
1. Поднять инфраструктуру: `make up` (в репозитории сервиса). Убедиться, что `docs` и `fixtures` доступны (рядом или через
`ARM_KA_DOCS`, `ARM_KA_FIXTURES` — последняя поддерживается только в `arm-core`, см. «Известные расхождения
реализаций»).
2. `make check` — быстрые проверки. Исправлять до зелёного.
3. `make check-full` — полная проверка (интеграционные тесты, образ; для `arm-core` — ещё DBML, миграции, `web`).
4. Если менялись `contracts/`, `model/` или спека в репозитории `docs`: в нём `make check`, затем в каждом затронутом
сервисе — пересинхронизация копий контракта и пункты 2–3.
5. Итог `make check-full` (зелёный/красный, число тестов, **что пропущено и почему**) вписать в описание PR.
Правила: PR создаётся только после зелёного `make check-full`; один OpenSpec change и один PR — на один репозиторий;
`make check-full` не заменяет ревью.
## Типичные проблемы
- **`make test-integration` падает на соединении** — не поднята инфраструктура: `make up`; порты заняты — поменять в
`docs/dev/.env` и в `api/.env.test`/`api/.env` сервиса (`README.md`).
- **`NoSuchBucket`** — бакета нет на свежем томе: `docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka
run --rm minio-init`.
- **`tabular-parser`: интеграционный тест получает чужие ответы** — на шине compose не должно работать другого
экземпляра сервиса из той же queue group (интеграционный тест поднимает сервис в процессе теста).
- **Нет `uv`/Python 3.11** — `make venv` их требует (версия Python — как на платформе).
- **`docker build` (и `npm install` в `web-check` у `arm-core`) падает без сети** — это шаги `check-full`, которым нужна сеть (образы, пакеты); `make check` сети не требует.
## Известные расхождения реализаций
Фактические реализации в `Makefile` трёх сервисов расходятся с описанной выше семантикой. Сервисы в рамках Среза 0
не менялись; расхождения занесены как находки ревью.
- **`make up`.** Эталон — `scan-gateway` (`--project-name arm-ka`, `--wait` со списком сервисов, затем `run --rm
minio-init`). `tabular-parser` не создаёт бакет (нет шага `minio-init`) и на свежем томе даёт `NoSuchBucket`;
`arm-core` выполняет `docker compose up -d` без `--wait`, то есть возвращается до готовности сервисов.
- **Структура `check-full`.** В `scan-gateway` шаги вызываются через `$(MAKE)` (порядок и остановка на первой ошибке
гарантированы и при `make -j`); в `arm-core` и `tabular-parser` — пререквизитами (при последовательном запуске
эквивалентно, при `make -j` порядок не гарантирован). Запускайте без `-j`.
- **`ARM_KA_DOCS`/`ARM_KA_FIXTURES`.** `arm-core` учитывает обе переменные; `tabular-parser` — только `ARM_KA_DOCS`
(тестов с маркером `fixtures` пока нет); `scan-gateway` — ни одну (ищет `../docs` и `../fixtures/ocr` по фиксированному
пути; тексты причин пропуска не содержат «ARM_KA_FIXTURES недоступен»). Пока расхождение не устранено, для
`scan-gateway` docs/fixtures нужно класть рядом.

236
dev/docker-compose.yml Normal file
View File

@ -0,0 +1,236 @@
# Инфраструктура разработки АРМ КА: PostgreSQL 16, NATS 2.10 (без JetStream, как на платформе), MinIO + бакет.
# Запуск: cp .env.example .env && docker compose up -d (из этого каталога)
# Ожидание готовности: docker compose up -d --wait postgres nats minio. Голый `up -d --wait` возвращает код 1, потому что
# одноразовый minio-init завершается (хотя и с кодом 0) — для скриптов указывайте сервисы явно.
# Все учётные данные, порты хоста и имя бакета берутся из .env (значения по умолчанию — dev).
# Порты публикуются только на 127.0.0.1; адрес привязки меняется через BIND_ADDR (см. .env.example).
# Профиль `services` — сервисы системы, собранные из соседних репозиториев workspace (../../<repo>, Dockerfile в корне):
# docker compose --profile services up -d --build
# docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator
# Одноразовые minio-init и arm-core-migrate (миграции + seed) в список --wait не входят: они завершаются с кодом 0.
# Внутри контейнера каждый сервис слушает 8000; на хост публикуются 8000 (arm-core), 8001 (tabular-parser), 8002 (scan-gateway).
# Фиксированное имя проекта: одинаковые тома и сети из любого worktree (.wt/<срез>/docs/dev).
name: arm-ka
# Общие переменные сервисов профиля `services` (формат шаблона команды; адреса — внутри сети compose, не localhost).
x-service-env: &service-env
DEBUG: "true"
OPENBAO__MINIO__MINIO_URL: minio:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN: ${MINIO_ROOT_USER:-minioadmin}
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
OPENBAO__MINIO__MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka}
OPENBAO__MINIO__MINIO_SECURE: "false"
OPENBAO__NATS__NATS_URL: nats://nats:4222
NATS_ENABLED: "true"
# arm-core и его одноразовые миграции: те же переменные плюс БД и dev-эмитент токенов.
x-arm-core-env: &arm-core-env
<<: *service-env
OPENBAO__DATABASE__DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-armka}:${POSTGRES_PASSWORD:-armka}@postgres:5432/${POSTGRES_DB:-armka}
# Dev-эмитент JWT (POST /api/v1/dev/token) — только при DEBUG=true; секрет публичный, только для dev.
# Порт хоста по умолчанию на 127.0.0.1 (BIND_ADDR): при BIND_ADDR=0.0.0.0 токен может получить любой в сети.
AUTH_DEV_ISSUER: "true"
AUTH_DEV_SECRET: dev-only-secret-do-not-use-in-production-0123456789
services:
postgres:
image: postgres:16.10-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-armka}
POSTGRES_USER: ${POSTGRES_USER:-armka}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-armka}
ports:
- "${BIND_ADDR:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 3s
timeout: 3s
retries: 20
start_period: 5s
# Core NATS без JetStream: флаг --jetstream не передаётся. Мониторинг — порт 8222 (/varz, /healthz).
nats:
image: nats:2.10.29-alpine
command: ["-m", "8222"]
ports:
- "${BIND_ADDR:-127.0.0.1}:${NATS_PORT:-4222}:4222"
- "${BIND_ADDR:-127.0.0.1}:${NATS_MONITOR_PORT:-8222}:8222"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8222/healthz || exit 1"]
interval: 3s
timeout: 3s
retries: 20
start_period: 2s
# Образы minio/minio и minio/mc сняты с Docker Hub и quay.io; используется сборка alpine/minio с RELEASE-тегом.
minio:
image: alpine/minio:RELEASE.2025-10-15T17-29-55Z
# Образ по умолчанию работает от пользователя minio и не может писать в свежий именованный том — в dev запускаем от root.
user: "0:0"
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "${BIND_ADDR:-127.0.0.1}:${MINIO_API_PORT:-9000}:9000"
- "${BIND_ADDR:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:9000/minio/health/live || exit 1"]
interval: 3s
timeout: 3s
retries: 20
start_period: 3s
# Одноразовое создание бакета; идемпотентно (--ignore-existing), при повторном up снова завершается с кодом 0.
minio-init:
image: bitnamilegacy/minio-client:2025.7.21
depends_on:
minio:
condition: service_healthy
restart: "no"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka}
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" &&
mc mb --ignore-existing "local/$$MINIO_BUCKET"
# --- Профиль `services` -------------------------------------------------------------------------------------------
# Одноразово: миграции arm-core (alembic upgrade head) и справочники с dev-пользователями (seed --dev).
# Оба шага идемпотентны; при каждом `up` профиля контейнер снова выполняется и завершается с кодом 0.
# Образ собирает сервис arm-core (build только у него): две сборки одного тега дают разные ID образа, и каждый
# следующий `up` пересоздавал бы arm-core-migrate и arm-core. pull_policy: never — образ локальный, не из реестра.
arm-core-migrate:
profiles: ["services"]
image: arm-core:dev
pull_policy: never
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment: *arm-core-env
command: ["sh", "-c", "alembic upgrade head && python -m scripts.seed --dev"]
arm-core:
profiles: ["services"]
build:
context: ../../arm-core
image: arm-core:dev
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
arm-core-migrate:
condition: service_completed_successfully
environment: *arm-core-env
ports:
- "${BIND_ADDR:-127.0.0.1}:${ARM_CORE_PORT:-8000}:8000"
healthcheck:
# curl в python:3.11-slim нет; /healthcheck всегда отвечает 200, поэтому читаем поле nats
test:
- CMD
- python
- -c
- "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' else 1)"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
tabular-parser:
profiles: ["services"]
build:
context: ../../tabular-parser
image: tabular-parser:dev
restart: unless-stopped
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment: *service-env
ports:
- "${BIND_ADDR:-127.0.0.1}:${TABULAR_PARSER_PORT:-8001}:8000"
healthcheck:
test:
- CMD
- python
- -c
- "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' else 1)"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
# Эмулятор ИС Сканирование: тот же образ, что у шлюза (собирает сервис scan-gateway, см. arm-core-migrate); общий
# том hotfolder с шлюзом. Переменные MinIO/NATS не нужны.
# Фикстуры ответов ИС — ../../fixtures/ocr (только чтение): набор <имя>.json + <имя>_*.json для входного файла UUID_<имя>.ext.
scan-emulator:
profiles: ["services"]
image: scan-gateway:dev
pull_policy: never
command: ["python", "-m", "src.scan_emulator"]
restart: unless-stopped
environment:
SCAN_HOTFOLDER_PATH: /data/hotfolder
SCAN_FIXTURES_DIR: /fixtures/ocr
SCAN_EMULATOR_DELAY: "2"
volumes:
- hotfolder:/data/hotfolder
- ../../fixtures/ocr:/fixtures/ocr:ro
scan-gateway:
profiles: ["services"]
build:
context: ../../scan-gateway
image: scan-gateway:dev
restart: unless-stopped
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment:
<<: *service-env
SCAN_HOTFOLDER_MODE: local
SCAN_HOTFOLDER_PATH: /data/hotfolder
volumes:
- hotfolder:/data/hotfolder
ports:
- "${BIND_ADDR:-127.0.0.1}:${SCAN_GATEWAY_PORT:-8002}:8000"
healthcheck:
# /healthcheck всегда отвечает 200, поэтому читаем поля nats и hotfolder
test:
- CMD
- python
- -c
- "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' and d['hotfolder']=='ok' else 1)"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
volumes:
postgres-data:
minio-data:
# Общая горячая папка scan-gateway и scan-emulator (Input/, Output/)
hotfolder:

View File

@ -0,0 +1,45 @@
# Срез 0 — сквозной скелет (docs) — фаза 1: контракт и инфраструктура — план исполнения (Срез 0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/docs`). **Issue:** #1.
**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`).
## Global Constraints
- Репозиторий `arm-ka-docs`: документы, контракты, `docs/dev/`. `docs/source/**` не редактировать.
- Контракт v1.0: три схемы JSON Schema 2020-12 (`recognition-request`, `recognition-result-message`, `recognition-result`), `schema_version` = `"1.0"`; статусы конверта `processing | done | needs_check | error`; коды ошибок — перечень из spec `recognition-contract` (включая `NOT_IMPLEMENTED`); деньги — строки `^-?\d+(\.\d{1,2})?$`; `Nats-Msg-Id`: `<job_id>:<attempt>`, `ack:<job_id>:<attempt>`, `result:<job_id>:<attempt>`.
- Платформа: Python 3.11, PostgreSQL 16, NATS 2.10 **без JetStream**, один бакет S3 `arm-ka`; Nexus недоступен; CI нет — `make check` / `make check-full`.
- Compose собирает сервисы из `../../<repo>` относительно `docs/dev/`; версии образов фиксированы.
- Язык документов и коммитов — русский; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
## Задачи
### Task 1: [1.1] [docs] `docs/contracts/recognition-result.schema.json`
- [x] [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о версионировании, добавить `NOT_IMPLEMENTED` в `error.code`; проверить `python -m jsonschema` на файле схемы (метасхема 2020-12 валидна)
### Task 2: [1.2] [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recogniti
- [x] [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum, форматы uuid/date-time); проверить метасхемой
### Task 3: [1.3] [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт
- [x] [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done | needs_check | error; условные обязательные поля через `if/then`); проверить метасхемой
### Task 4: [1.4] [docs] Примеры `docs/contracts/examples/`
- [x] [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`, `result-message-processing.json`, `result-message-done.json`, `result-message-error.json`, `result-osv-minimal.json`, `result-statement-minimal.json`, `result-error.json`; скрипт `docs/scripts/validate_contracts.py` валидирует все примеры схемами и возвращает 0
### Task 5: [1.5] [docs] `03-architecture.md`
- [x] [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила `Nats-Msg-Id`, ack через `processing`, повторная публикация; проверить, что текст ссылается на существующие файлы
### Task 6: [2.1] [docs] `docs/dev/docker-compose.yml`
- [x] [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`, `minio-init` (создаёт бакет `arm-ka`), healthcheck'и, именованные тома; `docs/dev/.env.example`; проверка: `docker compose up -d` → все healthy, `mc ls` видит бакет, `curl :8222/varz` отвечает
### Task 7: [2.2] [docs] `docs/dev/README.md`
- [x] [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus (`make venv` фильтрует `raisa_*`), черновик — дописывается в 5.4

View File

@ -0,0 +1,45 @@
# Срез 0 — сквозной скелет (docs) — фаза 2: проверки, профиль services, документы — план исполнения (Срез 0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/docs`). **Issue:** #1.
**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`).
## Global Constraints
- Репозиторий `arm-ka-docs`: документы, контракты, `docs/dev/`. `docs/source/**` не редактировать.
- Контракт v1.0: три схемы JSON Schema 2020-12 (`recognition-request`, `recognition-result-message`, `recognition-result`), `schema_version` = `"1.0"`; статусы конверта `processing | done | needs_check | error`; коды ошибок — перечень из spec `recognition-contract` (включая `NOT_IMPLEMENTED`); деньги — строки `^-?\d+(\.\d{1,2})?$`; `Nats-Msg-Id`: `<job_id>:<attempt>`, `ack:<job_id>:<attempt>`, `result:<job_id>:<attempt>`.
- Платформа: Python 3.11, PostgreSQL 16, NATS 2.10 **без JetStream**, один бакет S3 `arm-ka`; Nexus недоступен; CI нет — `make check` / `make check-full`.
- Compose собирает сервисы из `../../<repo>` относительно `docs/dev/`; версии образов фиксированы.
- Язык документов и коммитов — русский; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
## Задачи
### Task 1: [4.1] [docs] `docs/dev/checks.md`
- [x] [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`), что делает каждая, порядок перед PR, как пропускаются тесты без фикстур/docs; проверка: цели `arm-core` соответствуют описанию
### Task 2: [4.2] [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` се
- [x] [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или копируется); проверка: `make check` в трёх репозиториях вызывает одинаковые команды (`diff` общих целей)
### Task 3: [5.3] [docs] `docker-compose.yml`
- [x] [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway` (8002), `scan-emulator` (том `hotfolder`, `../../fixtures/ocr:ro`); проверка: `--profile services up --build`, все `/healthcheck` 200 с `nats = ok`
### Task 4: [5.4] [docs] Сквозная проверка руками по README
- [x] [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт arm-core) → паблишер публикует → `tabular-parser` отвечает ack и `NOT_IMPLEMENTED` → `recognition_job.status` проходит `published → processing → error`, `error_code = NOT_IMPLEMENTED`; то же для `image` через эмулятор (`raw/{job_id}.json` появляется в MinIO); остановить `tabular-parser`, вставить задание — через 30 с повторная публикация, после трёх — `dlq`; зафиксировать в README как «проверка окружения»
### Task 5: [6.1] [docs] `06-test-data.md` §6
- [x] [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила элиминации, права — `arm-core/api/seed/`; `09-delivery-plan.md` Срез 0: решение D-1 по пакету контрактов, ссылка на `docs/dev/`, проверки вместо CI
### Task 6: [6.2] [docs] `07-open-questions.md`
- [x] [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`; обязательность ОСВ расширенного подхода; источник полного плана счетов 1С) и расхождения макета из задачи B; статусы допущений
### Task 7: [6.3] [docs] Итоговая проверка
- [x] [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки между документами ведут на существующие файлы (`grep` по именам файлов)

View File

@ -144,3 +144,35 @@ PostgreSQL 16, схемы создавать можно. Остаются (не
| 10 | 2026-10-03 | D-9: `python:3.11-slim`, PostgreSQL 16, NATS 2.10 без JS, `node:22` до ответа | ответы пользователя; Node неизвестен | смена тега в compose/Dockerfile |
| 11 | 2026-10-03 | Worktree среза — зеркальный workspace `.wt/<срез>/` с симлинками | относительные пути `../docs`, `../fixtures` и хуки должны работать в ветке | переместить worktree |
| 12 | 2026-10-03 | D-10: `raisa_*` фильтруются при dev-установке, npm-пакеты `raisa-*` → `optionalDependencies` | Nexus недоступен до прода | вернуть две строки в `dependencies` |
| 13 | 2026-10-03 | Файл результата: пустой `documents` допустим только при `status = error` — выражено в схеме условием `if/then` (`minItems: 1`) | требование spec `recognition-contract` «Файл результата»; текст задачи 1.1 его не перечислял | ослабить/убрать условие в схеме, пересобрать копии контракта в сервисах |
| 14 | 2026-10-03 | Compose: фиксированное имя проекта `name: arm-ka`, порты хоста переопределяются через `.env` со значениями по умолчанию из spec | одинаковые тома/сети из любого worktree; порты 5432/4222 на машине разработчика могут быть заняты | смена имени проекта — потеря dev-томов (`down -v`), порты — правка `.env` |
| 15 | 2026-10-03 | Чекбоксы плана и `tasks.md` отмечает контроллер SDD отдельным коммитом после чистого ревью задачи | исполнитель не должен отмечать выполненным то, что ещё не прошло ревью | отметки правятся вручную |
| 16 | 2026-10-03 | Compose: MinIO берётся из неофициальных образов `alpine/minio:RELEASE.2025-10-15T17-29-55Z` и `bitnamilegacy/minio-client:2025.7.21` | `minio/minio` и `minio/mc` недоступны на Docker Hub (denied), на quay.io нужных тегов нет; для dev достаточно любого S3-совместимого MinIO нужной версии API | образы сторонние: при появлении доступа к банковскому реестру/официальным образам — заменить две строки `image:` в `dev/docker-compose.yml`; в прод эти образы не попадают |
| 17 | 2026-10-03 | Compose: сервис `minio` запускается от `user: "0:0"` | образ `alpine/minio` по умолчанию работает от пользователя `minio` и падает на свежем именованном томе | только dev; удалить строку, когда образ/том позволят писать без root |
| 18 | 2026-10-03 | `dev/.env.example` дополнен заглушками `OPENBAO__SMB__*` и `NATS_ENABLED=true` | `Settings` шаблона (arm-core) требует блок `OPENBAO.SMB`, без него сервис из venv не стартует; `NATS_ENABLED` по умолчанию `false`, и `/healthcheck` показывает `nats = off`; spec велит копировать именно `docs/dev/.env.example` | убрать две группы строк; сервисы с другим набором обязательных настроек получат лишние (игнорируемые) переменные Примечание: часть про обязательность SMB устарела, см. №44. |
| 19 | 2026-10-03 | `schema_version` во всех трёх схемах: `const "1.0"` заменён на `type: string, pattern: ^1\.\d+$` (производитель v1.0 пишет `"1.0"`); в `$comment` и в 03-architecture.md добавлено правило «новый MINOR раскатывается сначала на потребителей» | spec «Версионирование контракта»: потребитель принимает тот же MAJOR и любой MINOR, сервис «поддерживающий 1.x» принимает 1.1 и отвергает только 2.0; с `const` это неисполнимо, а копии схем уйдут в сервисы побайтно (финальное ревью I-1) | вернуть `const "1.0"` и пересобрать копии схем в сервисах; побочный риск — копии в `arm-core`/`tabular-parser`/`scan-gateway` нужно обновить (тест идентичности контракта упадёт, пока копии не обновлены) |
| 20 | 2026-10-03 | Compose: порты хоста публикуются на `${BIND_ADDR:-127.0.0.1}`, а не на 0.0.0.0 | пароли по умолчанию (`armka`, `minioadmin`); все сценарии идут через localhost; spec bind-адрес не оговаривает (финальное ревью M-6) | если нужен доступ с другой машины — `BIND_ADDR=0.0.0.0` в `.env`; для запуска профиля `services` порты хоста не нужны |
| 21 | 2026-10-04 | В корне репозитория `docs` добавлен `Makefile` с целями `venv`, `validate-contracts`, `validate-openspec`, `check` (сервисных целей нет); `jsonschema` ставится в `.venv` (в `.gitignore`) | дополнение контроллера к задаче 4.1: проверки контракта и спеки должны запускаться одной командой, как в сервисах; `check` = `scripts/validate_contracts.py` + `openspec validate slice-0-skeleton --strict` | удалить `Makefile` и строку `.venv/` в `.gitignore`; команды из `dev/checks.md` останутся запускаемыми вручную |
| 22 | 2026-10-04 | `dev/checks.md` описывает общие цели по эталону `scan-gateway` (`up` с `--project-name arm-ka`, `--wait postgres nats minio` и `run --rm minio-init`; `check-full` как последовательность шагов), расхождения `arm-core`/`tabular-parser` и неполная поддержка `ARM_KA_DOCS`/`ARM_KA_FIXTURES` в `scan-gateway` вынесены в раздел «Известные расхождения реализаций», код сервисов не менялся | spec «Одинаковый набор целей» требует единой семантики, а фактические `Makefile` расходятся; правка сервисов вне задачи 4.1 и их PR | если эталон выбран неверно — переписать раздел `make up` в `checks.md`; расхождения останутся незакрытыми, пока сервисы не выровнены |
| 23 | 2026-10-04 | В `dev/README.md` раздел «Интеграционные тесты сервисов против compose» заменён ссылкой на `checks.md`; убрано утверждение «Makefile есть только у `arm-core`» | утверждение устарело (Makefile есть у всех трёх сервисов), единый источник описания проверок — `checks.md` | вернуть старый текст раздела |
| 24 | 2026-10-04 | `dev/Makefile.common` — эталон общих целей по `scan-gateway`; различия сервисов вынесены в переменные (`MYPY_PATHS`, `IMAGE`, `COMPOSE_*`, `CHECK_FULL_STEPS`), `check-full` реализован через `$(foreach …)` с вызовом `$(MAKE)` на каждый шаг; в docs-репозитории `Makefile.common` не `include`-ится, сервисы его пока не подключают | задача 4.2 требует эталонный фрагмент и сверку `diff`ом; Makefile сервисов в Срезе 0 не правятся (код готов, PR открыты) — `include` в них можно ввести без смены поведения следующим срезом | сервисы продолжают держать копии рецептов; расхождения копий вылезают только при правке одной из них (сверка — `make -n`, см. `checks.md`) |
| 25 | 2026-10-04 | Замечания T1.1–T1.4 ревью задачи 4.1 исправлены в `dev/checks.md` (fixtures и «ARM_KA_FIXTURES» — только `arm-core`, `mypy src scripts`, «Инфраструктура compose», «одинаковая семантика» заменена ссылкой на расхождения) | прямое поручение контроллера; формулировки приведены в соответствие с фактическими Makefile и тестами | неточная документация; правится одной строкой |
| 26 | 2026-10-04 | Профиль `services` в `dev/docker-compose.yml`: переменные сервисов — общие якоря YAML (`x-service-env`, `x-arm-core-env`) с адресами внутри сети compose и `DEBUG=true`; образы собираются локально с тегами `arm-core:dev`, `tabular-parser:dev`, `scan-gateway:dev`; healthcheck контейнеров — `python -c` с чтением поля `nats` (у `scan-gateway` ещё `hotfolder`) из `/healthcheck`; порты хоста `${ARM_CORE_PORT:-8000}`/`${TABULAR_PARSER_PORT:-8001}`/`${SCAN_GATEWAY_PORT:-8002}` на `${BIND_ADDR:-127.0.0.1}`, в контейнере 8000 | spec dev-environment («`OPENBAO__*` шаблона, `DEBUG=true`»), фрагмент `scan-gateway` (`open-questions-proposal.md`) как основа; `curl` в `python:3.11-slim` нет, а `/healthcheck` всегда 200; якоря убирают тройное дублирование учётных данных MinIO/NATS | правка одного блока compose; при смене формата `/healthcheck` сервиса — правка трёх строк `test` |
| 27 | 2026-10-04 | Одноразовый сервис `arm-core-migrate` (`alembic upgrade head && python -m scripts.seed --dev`, `depends_on` postgres `service_healthy`); `arm-core` ждёт его `service_completed_successfully`; выполняется при каждом `up` профиля | дополнение контроллера к задаче 5.3; без схемы `dkb` и справочников ядро не работает, оба шага идемпотентны (повторный seed — `0 / 0 / N`); `--dev` — нужны dev-пользователи для dev-токена | лишний запуск миграций при каждом `up` (~2 с); убрать `--dev` — одна строка |
| 28 | 2026-10-04 | Образ каждого репозитория собирает один сервис: `build` только у `arm-core` и `scan-gateway`; `arm-core-migrate` и `scan-emulator` используют тег `arm-core:dev`/`scan-gateway:dev` с `pull_policy: never` (отступление от фрагмента `scan-gateway`, где `build` у обоих) | проверено: при `build` у двух сервисов с одним тегом compose 2.34 собирает образ дважды с разными ID, метка `com.docker.compose.image` контейнера расходится с тегом, и каждый следующий `up` пересоздаёт `arm-core-migrate` и `arm-core` (флип-флоп); с одной сборкой три `up` подряд — 0 пересозданий | `up -d scan-emulator` или `arm-core-migrate` в одиночку на машине без образа падает `No such image` — сначала `docker compose --profile services build`; вернуть `build` — две строки |
| 29 | 2026-10-04 | В compose у `arm-core` включён dev-эмитент токенов (`AUTH_DEV_ISSUER=true`, публичный `AUTH_DEV_SECRET` из `arm-core/api/.env.example`) | ручные проверки `/api/v1/me` и сквозной сценарий (задача 5.4) без РАИСА; маршрут существует только при `DEBUG=true`; порт хоста по умолчанию на 127.0.0.1 | при `BIND_ADDR=0.0.0.0` токен любого dev-пользователя может получить любой в сети — только dev; выключить — одна строка |
| 30 | 2026-10-04 | Сквозная проверка окружения (задача 5.4) выполняется вставкой строк `legal_entity` (синтетическое, ИНН `7700000001`, `on conflict do nothing`) / `s3_object` / `stored_file` / `recognition_job(queued)` через `psql` в контейнере `postgres` и загрузкой исходника в `uploads/{file_id}/{job_id}/{имя}` через `mc` из образа `minio-init`; рецепт — функции `dpsql`/`dmc`/`new_job`/`poll` в `dev/README.md` («Проверка окружения»); тестовые строки и объекты не удаляются | API загрузки файлов в Срезе 0 нет, тестовых скриптов `arm-core` для рабочей БД тоже (его интеграционные тесты работают во временной БД); ключ — по «Схеме ключей S3» `03-architecture.md`; рецепт проверен прогоном дословно, но при уже существовавшем `legal_entity` с этим ИНН: ветка вставки без конфликта на чистой БД не выполнялась | вставка в обход будущего API может разойтись с ним (обязательные поля `stored_file`/`document`) — рецепт придётся обновить в Срезе 1/2; мусорные строки в dev-БД — сброс `down -v` |
| 31 | 2026-10-04 | Ожидаемый итог сценария `image` в README — `error` / `INVALID_DOCUMENT` (фактический ответ), а не `NOT_IMPLEMENTED`; файл — `ocr/aep_5.PDF` (так он назван в `fixtures`, не `aep_5.pdf`) | обе пары `fixtures/ocr` (`aep_5.json`, `aep_8_form4.json`) — ответы-ошибки ИС, эмулятор отдаёт их как есть, шлюз пробрасывает код ИС; на регистрозависимой ФС `aep_5.pdf` не найдётся | успешный путь `image` (`done`/`needs_check`, `results/…`) сквозной проверкой не покрыт, пока в `fixtures/ocr` нет успешного ответа ИС |
| 32 | 2026-10-04 | Замечание T3.6 проверено эмпирически: образы `scan-gateway:dev` и `arm-core:dev` пересобраны `build --no-cache` (новые ID) на работающем профиле; после пересоздания контейнеров старые ID образов в `docker image ls -a` не остались (`No such image`) | формулировку README нужно было подтвердить, а без смены ID образа compose ничего не пересоздаёт | ~1 мин сборки и загрузка зависимостей из PyPI; на окружение не влияет (образы — те же исходники) |
| 33 | 2026-10-04 | В README для наблюдения за шиной предложен `natsio/nats-box:0.17.0` (`nats sub 'recognition.>'` в сети `arm-ka_default`) — разовый `docker run --rm`, не сервис compose | без него `published`/`processing` у `tabular` (десятки мс) не видны; образ доступен с Docker Hub (проверено), в compose не добавлялся, чтобы не менять состав окружения | при недоступности образа — только логи `arm-core` и метки времени задания; шаг опциональный |
| 34 | 2026-10-04 | Правим спеку и документ под фактическое поведение сервисов, а не код: при неизвестном MAJOR `schema_version` (и при запросе не по схеме) сервис публикует `error SCHEMA_MISMATCH` **без** отдельного ack `processing`; формулировка «и подтверждает ack'ом» (T12.13) из `spec.md` (сценарий «Несовместимая версия запроса») и `03-architecture.md` убрана. Конверт публикуется, только если извлекаются корректные `job_id`/`file_id`, и не публикуется для `job_id`, уже находящегося в работе/обработанного | `tabular-parser/api/src/core/worker.py` (`_reject`: «`SCHEMA_MISMATCH` без ack», строки 242–275) и `scan-gateway/api/src/core/worker.py` (`_reject`, R-16: строки 288–321) ведут себя одинаково; финальный конверт сам закрывает задание в ядре (`published → error` допустим, `recognition_exchange.py`: результат принимается из `queued/published/processing`), ack перед ним ничего не добавляет, а `Nats-Msg-Id` ack и результата разные; конверт для `job_id` в работе совпал бы по `result:<job_id>:<attempt>` с настоящим результатом | если ядро когда-нибудь начнёт требовать ack перед результатом, задание с `SCHEMA_MISMATCH` останется в `published` до повторов и `dlq`; правка — одна строка в воркерах и спеке |
| 35 | 2026-10-04 | Запрос, из которого нельзя извлечь `job_id` (не JSON, не объект, нет/не uuid `job_id`), остаётся без ответа: сервис пишет лог и отбрасывает его; ядро закрывает задание по `NO_ACK` / `dlq` после `OUTBOX_MAX_REPUBLISH` повторов. Записано в `spec.md` (новый сценарий «Нечитаемый запрос») и `03-architecture.md` | собрать валидный конверт без `job_id` нельзя (схема требует uuid), а выдуманный `job_id` ядро всё равно не сопоставит с заданием; оба сервиса логируют и сбрасывают (`worker.py`: tabular строки 253–263, scan-gateway 292–299); ядро и так закрывает «молчание» (`recognition_exchange.py`: `NO_ACK`, `dlq`) | нет худшего исхода, чем 120 с до `dlq` (4 × `ACK_TIMEOUT_SECONDS`) для заведомо битого запроса, которого ядро само не порождает; сокращать задержку можно только чтением `job_id` из заголовка `Nats-Msg-Id` — вне Среза 0 |
| 36 | 2026-10-04 | `PUBLISH_RETRIES` в спеке (новое требование «Повторы публикации ответов сервисом») = **одна первая попытка + до 3 повторов** (всего не более 4 попыток) с тем же `Nats-Msg-Id` и паузой между попытками; исчерпаны — error-лог, задание закрывает ядро (`reply_deadline`). Пауза — деталь реализации (`tabular-parser` 2,0 с, `scan-gateway` 0,5 с), в спеку не включена | значения в коде: `scan-gateway/api/src/core/config.py:63` (`PUBLISH_RETRIES = 3`, «повторы после первой попытки (всего до 4)») и `core/nats.py:245–256` (`attempts_total = retry_count + 1`); `tabular-parser/api/src/core/config.py:49` (`PUBLISH_RETRIES = 3`, `NonNegativeInt`) и `core/nats.py:306` (`attempts = publish_retries + 1`); паузы различаются (`tabular-parser` — `PUBLISH_RETRY_PAUSE = 2.0` с в `core/nats.py:51`, `scan-gateway` — `RETRY_DELAY = 0.5` с в `core/nats.py:49`), семантика одна | если ядро/сервис договорятся считать `PUBLISH_RETRIES` общим числом попыток, расхождение «3 против 4» не ломает обмен (дедуп по `Nats-Msg-Id`); правка — одна фраза в спеке |
| 37 | 2026-10-04 | Пример `contracts/examples/request-image.json` с `reply_deadline` в прошлом **не меняется**; в `spec.md` (сценарий «Примеры контракта — иллюстрации») и `03-architecture.md` записано, что примеры — иллюстрации формы, схема проверяет у `reply_deadline` лишь формат `date-time`, а срок проверяет сервис (`scan-gateway` — `error TIMEOUT`, `scan-gateway/api/src/core/worker.py:469–475`; `tabular-parser` срок не проверяет: `reply_deadline` в его воркере не используется) | копии `examples/` в сервисах сверяются побайтно тестами идентичности: правка примера = пересинхронизация трёх репозиториев ради документальной оговорки; `validate_contracts.py` проходит (rc 0, 8 примеров) | если кто-то возьмёт `request-image.json` как готовое задание для `scan-gateway`, получит `TIMEOUT` — это зафиксировано оговоркой; обновить дату позже можно одним PR с пересинхронизацией |
| 38 | 2026-10-04 | `06-test-data.md` §6 и `09-delivery-plan.md` (Срез 0) приведены к факту: `fixtures/directories/` — только тестовые ЮЛ/ГК (сейчас пустой `.gitkeep`, наполнение — Срез 1); seed продукта лежит в `arm-core/api/seed/core/` (+ `seed/dev/` только для `--dev`); в плане Среза 0 добавлена ссылка на `dev/README.md` («Профиль `services`», «Проверка окружения»); `00-workspace-layout.md` не менялся (неактуальных фактов нет) | перечень файлов seed и число ролей/прав проверены по `arm-core/api/seed/` (7 ролей, 16 прав, 122 строки плана счетов); решение D-1, compose и проверки вместо CI в `09` уже были верны | только неточная ссылка в документах; правится правкой текста |
| 39 | 2026-10-04 | Реестр `07-open-questions.md`: 36 новых строк OQ-48…OQ-83 вместо ~60 пунктов трёх `open-questions-proposal.md` и находок контроллера — пересекающиеся пункты слиты в одну строку с перечнем источников: PUBLISH_RETRIES (scan-gateway п. 1 + O-25), `reply_deadline` (п. 12 + O-26), нечитаемый запрос (tabular P-1 + O-24), ack при `SCHEMA_MISMATCH` (tabular P-2 + scan-gateway п. 2), `attempt + 1` (tabular P-3 + scan-gateway п. 17), фикстуры OCR (п. 3 + O-15), `make up`/`check-full` (O-1, O-2, O-4, O-5 + scan-gateway п. 18), общий NATS (п. 11 + O-11), `stored_file.status` (arm-core предл. 19 + O-18), `republish_count` (предл. 10 + O-19), append-only аудит (предл. 9 + дополнение), срок ответа (предл. 11 + дополнение) | один вопрос — одна строка: иначе человек решает одно и то же дважды, а реестр несёт противоречивые статусы; перечень источников (репозиторий + маркер) сохраняет прослеживаемость, маркеры не менялись | если слияние оказалось неудачным, строка делится на две с новым номером; нумерация OQ-48…OQ-83 сплошная, на неё могут ссылаться только новые документы |
| 40 | 2026-10-04 | Статусы OQ-45/46/47 обновлены в существующих строках, не продублированы: все три — «допущение принято и применено в Срезе 0» с фактами (NU: контракт оставляет, ORM `OsvDataType` 1:1 с DBML v5.13, `NoopMapper`; OQ-46: 27 строк EXTENDED + 10 BASIC в seed, `is_mandatory = true`; OQ-47: 122 строки плана счетов, названия нестандартных субсчетов выведены по роли в §4). Кандидаты DBML v5.14 (`NU`, `NULLS NOT DISTINCT`, note `article_2_line`, действие `audit_action`, счётчик republish, `lower(email)`) внесены только строками реестра, файл `data-model-v5.13.dbml` не менялся | факты проверены чтением `arm-core/api/seed/core/`, `api/src/models/enums.py` и `contract/common.py`; создание DBML-версии — отдельное решение человека (CLAUDE.md: версия не меняется задним числом) | если человек решит иначе (например, хранить НУ), статус строки меняется правкой реестра, код Среза 0 не затронут |
| 41 | 2026-10-04 | В реестр **не** внесены, а оставлены в отчёте задачи 6 (кандидаты в follow-up issue при закрытии): scan-gateway п. 7 (рестарт NATS не автоматизирован), п. 8 (`stop()` не публикует конверты принятым заданиям), п. 9 (`/healthcheck` всегда 200), п. 13 (`DEBUG` не используется), п. 14 (`ResourceWarning` aiohttp), п. 15 (SMB без `connection_timeout`); tabular P-4, scan-gateway п. 10 и 16 (порты, `SCAN_GATEWAY_PORT`, устаревший README) — закрыты задачами 3–5 и `dev/README.md`; O-22 и T3.7/O-20 — процессные пункты задачи 7 | это инженерные доработки или уже исправленные расхождения документов, вопроса к человеку/аналитикам в них нет; реестр остаётся списком решений, которые принимает человек | пункты могут потеряться, если follow-up issue не будет заведена (чеклист закрытия, п. 6): они перечислены в отчёте с маркерами |
| 42 | 2026-10-04 | Формулировка статусов в новых строках: «допущение принято: …» — в коде/спеке Среза 0 действует рабочее допущение, вопрос открыт для подтверждения; «вопрос: …» — человек должен принять решение, допущение Среза 0 указано после двоеточия (аудит на уровне БД, `attempt + 1`, образцы ответов ИС, формат `data_ocr`, имя файла ответа, `.part` в `Input`) | правило файла: агент вопросы не закрывает, а помечает допущение; слово «закрыт» нигде не использовано | различение статусов — рекомендация, не контракт; при несогласии префикс правится в реестре |
| 43 | 2026-10-04 | Сценарий spec `dev-environment` «Запуск из venv против compose без Nexus» (`make venv` + `python run_server.py` + healthcheck) в Срезе 0 **не прогонялся**; раздел «Запуск сервисов из venv без Nexus» в `dev/README.md` остаётся черновиком, вопрос вынесен в OQ-84 (кандидат в follow-up issue) | проверка требует создать `.venv` внутри сервисных репозиториев (`make venv` пишет в `api/.venv`), а в фазе 2 они были доступны только для чтения (их worktree с открытыми PR не правятся); сквозная проверка шла на профиле `services` (контейнеры из тех же исходников) | сценарий spec может оказаться неработоспособным (набор переменных, `requirements.txt` без `raisa_*`, порты) — разработчик без Docker для сервисов узнает об этом при первом запуске; правится README или `.env.example` |
| 44 | 2026-10-04 | Уточнение к №18: блок `OPENBAO__SMB__*` **не обязателен**. В `arm-core` `OpenBAOSettings.SMB = SMBSettings()` с пустыми значениями по умолчанию (`api/src/core/config.py`), в `scan-gateway` блок необязателен в режиме `SCAN_HOTFOLDER_MODE=local` (по умолчанию) и обязателен целиком при `smb`, в `tabular-parser` блока нет, и `OpenBAOSettings` отвергает неизвестные ключи (`extra_forbidden`; проверено на копии его `config.py` с `.env` из `dev/.env.example`: падает на `OPENBAO.smb`, так же упадёт на `OPENBAO__DATABASE__*`) — `extra="ignore"` относится только к верхнему уровню `Settings`. Заглушки в `dev/.env.example` оставлены; комментарий и README исправлены и говорят: `.env.example` годится для `arm-core` как есть; для `scan-gateway` из него нужно убрать блок `DATABASE` (в его `OpenBAOSettings` поля нет, `extra_forbidden`), для `tabular-parser` — блоки `DATABASE` и `SMB` (уточнено 04.10 по ревью, N-1) | в момент решения №18 `Settings` arm-core требовал блок; затем в arm-core блок сделан необязательным (в Срезе 0 SMB ядру не нужен), а формулировка осталась в `.env.example` и README | ложное утверждение «обязателен» заставляло бы переносить заглушки в окружение, где они не нужны; цена низкая — у `arm-core` заглушки безвредны, а `scan-gateway` и `tabular-parser` с неотредактированным `.env.example` не стартуют (описано в README, часть OQ-84) |

View File

@ -21,11 +21,33 @@
заголовком `Nats-Msg-Id` = `<job_id>:<attempt>`
#### Scenario: Сообщение не по схеме
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`)
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` и не падает
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются
корректные `job_id` и `file_id`
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack
`processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии
реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Нечитаемый запрос
- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или
не uuid), а значит нельзя собрать валидный конверт
- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит
публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`)
#### Scenario: Невалидный повтор запроса, уже находящегося в работе
- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом
- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result:<job_id>:<attempt>` совпал бы с настоящим
результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
#### Scenario: Примеры контракта — иллюстрации
- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в
прошлом)
- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с
текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело
сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование)
### Requirement: Подтверждение взятия в работу (ack без JetStream)
Получив запрос, сервис SHALL немедленно, до чтения файла из S3, опубликовать в `recognition.result` конверт со
Получив корректный запрос (прошедший проверку схемы и `MAJOR`), сервис SHALL немедленно, до чтения файла из S3, опубликовать в `recognition.result` конверт со
`status = processing`, `job_id`, `file_id`, `service {name, version}` и заголовком `Nats-Msg-Id = ack:<job_id>:<attempt>`.
Ядро SHALL перевести задание `published → processing` и записать `started_at`, `service_name`, `service_version`. Если
ack не получен за `ACK_TIMEOUT_SECONDS` (по умолчанию 30), ядро SHALL опубликовать запрос повторно с тем же
@ -82,8 +104,12 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f
#### Scenario: Несовместимая версия запроса
- **WHEN** сервис, поддерживающий `1.x`, получает запрос `schema_version = "2.0"`
- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH`, запрос подтверждается (ack), чтобы не
зациклить доставку
- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на
запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не
будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого
запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его
отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
### Requirement: Идемпотентность результатов без брокера
Публикующий SHALL ставить заголовок `Nats-Msg-Id` на каждое сообщение (запрос `<job_id>:<attempt>`, ack
@ -95,6 +121,23 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f
- **WHEN** сервис опубликовал один и тот же результат два раза (повтор публикации после сетевой ошибки)
- **THEN** задание изменено один раз, второе сообщение проигнорировано
### Requirement: Повторы публикации ответов сервисом
Сервис SHALL публиковать ack и результат с заголовком `Nats-Msg-Id` и при временной ошибке публикации (потеря соединения,
таймаут подтверждения сервера) повторять публикацию с тем же `Nats-Msg-Id`: **одна первая попытка и до
`PUBLISH_RETRIES` повторов** (значение по умолчанию 3, то есть не более 4 попыток), с паузой между попытками; пауза —
деталь реализации. Повторы исчерпаны — сервис пишет error-лог и не публикует это сообщение снова; задание закрывается
на стороне ядра (не пришёл результат — `reply_deadline` → `timeout`; не пришёл ack — повтор запроса и `dlq`, но результат,
дошедший без ack, ядро принимает и в статусе `published`). Дубликаты, появившиеся из-за повтора,
безопасны: ядро дедуплицирует их по `Nats-Msg-Id`.
#### Scenario: Временный сбой публикации
- **WHEN** публикация результата упала с временной ошибкой, а следующая попытка успешна
- **THEN** сообщение опубликовано с прежним `Nats-Msg-Id`; ядро изменит задание один раз, даже если первая попытка тоже дошла
#### Scenario: Повторы исчерпаны
- **WHEN** все 1 + `PUBLISH_RETRIES` попыток публикации результата неуспешны
- **THEN** сервис пишет error-лог и продолжает работу (процесс не падает), а ядро закрывает задание по `reply_deadline`
### Requirement: Копия контракта в сервисе идентична источнику
Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check`
их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением).

View File

@ -5,26 +5,26 @@
## 1. Контракт v1.0 (первым — от него зависят все сервисы)
- [ ] 1.1 [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о
- [x] 1.1 [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о
версионировании, добавить `NOT_IMPLEMENTED` в `error.code`; проверить `python -m jsonschema` на файле схемы
(метасхема 2020-12 валидна)
- [ ] 1.2 [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum,
- [x] 1.2 [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum,
форматы uuid/date-time); проверить метасхемой
- [ ] 1.3 [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done |
- [x] 1.3 [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done |
needs_check | error; условные обязательные поля через `if/then`); проверить метасхемой
- [ ] 1.4 [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`,
- [x] 1.4 [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`,
`result-message-processing.json`, `result-message-done.json`, `result-message-error.json`, `result-osv-minimal.json`,
`result-statement-minimal.json`, `result-error.json`; скрипт `docs/scripts/validate_contracts.py` валидирует все
примеры схемами и возвращает 0
- [ ] 1.5 [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила
- [x] 1.5 [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила
`Nats-Msg-Id`, ack через `processing`, повторная публикация; проверить, что текст ссылается на существующие файлы
## 2. Инфраструктура разработки
- [ ] 2.1 [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`,
- [x] 2.1 [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`,
`minio-init` (создаёт бакет `arm-ka`), healthcheck'и, именованные тома; `docs/dev/.env.example`; проверка:
`docker compose up -d` → все healthy, `mc ls` видит бакет, `curl :8222/varz` отвечает
- [ ] 2.2 [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus
- [x] 2.2 [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus
(`make venv` фильтрует `raisa_*`), черновик — дописывается в 5.4
## 3. Ядро (детали — `arm-core/openspec/changes/slice-0-skeleton/tasks.md`)
@ -43,10 +43,10 @@
## 4. Проверки качества (параллельно с 3.x, после первых тестов)
- [ ] 4.1 [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`,
- [x] 4.1 [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`,
`check-full`), что делает каждая, порядок перед PR, как пропускаются тесты без фикстур/docs; проверка: цели
`arm-core` соответствуют описанию
- [ ] 4.2 [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или
- [x] 4.2 [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или
копируется); проверка: `make check` в трёх репозиториях вызывает одинаковые команды (`diff` общих целей)
## 5. Сервисы распознавания (детали — в их change'ах) и профиль `services`
@ -55,10 +55,10 @@
`processing` и заглушкой `NOT_IMPLEMENTED`, Dockerfile 3.11, `make check-full`
- [ ] 5.2 [scan-gateway] Вырезать фронт/БД, вендоренный контракт, горячие папки (local/smb), эмулятор, подписчик queue group
`scan-gateway` с ack и таймаутом, Dockerfile 3.11, `make check-full`
- [ ] 5.3 [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway`
- [x] 5.3 [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway`
(8002), `scan-emulator` (том `hotfolder`, `../../fixtures/ocr:ro`); проверка: `--profile services up --build`,
все `/healthcheck` 200 с `nats = ok`
- [ ] 5.4 [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт
- [x] 5.4 [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт
arm-core) → паблишер публикует → `tabular-parser` отвечает ack и `NOT_IMPLEMENTED` → `recognition_job.status`
проходит `published → processing → error`, `error_code = NOT_IMPLEMENTED`; то же для `image` через эмулятор
(`raw/{job_id}.json` появляется в MinIO); остановить `tabular-parser`, вставить задание — через 30 с повторная
@ -66,11 +66,11 @@
## 6. Документы контекста и закрытие
- [ ] 6.1 [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила
- [x] 6.1 [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила
элиминации, права — `arm-core/api/seed/`; `09-delivery-plan.md` Срез 0: решение D-1 по пакету контрактов,
ссылка на `docs/dev/`, проверки вместо CI
- [ ] 6.2 [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`;
- [x] 6.2 [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`;
обязательность ОСВ расширенного подхода; источник полного плана счетов 1С) и расхождения макета из задачи B;
статусы допущений
- [ ] 6.3 [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки
- [x] 6.3 [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки
между документами ведут на существующие файлы (`grep` по именам файлов)

View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12.
Запуск из любого cwd: python scripts/validate_contracts.py
Зависимости: стандартная библиотека + jsonschema.
Код возврата: 0 — всё валидно, 1 — любая ошибка.
"""
import datetime
import json
import re
import sys
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parent.parent
CONTRACTS = ROOT / "contracts"
EXAMPLES = CONTRACTS / "examples"
SCHEMAS = {
"request": CONTRACTS / "recognition-request.schema.json",
"message": CONTRACTS / "recognition-result-message.schema.json",
"result": CONTRACTS / "recognition-result.schema.json",
}
# Порядок важен: result-message-* проверяется раньше result-*.
PATTERNS = [
("request-", "request"),
("result-message-", "message"),
("result-", "result"),
]
_DATE_TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
def _check_date_time(value):
# Собственная проверка вместо rfc3339-validator: нужна и дата, и время, и часовой пояс.
if not isinstance(value, str):
return True
if not _DATE_TIME_RE.match(value):
return False
datetime.datetime.fromisoformat(value.replace("z", "Z")) # ValueError -> формат не прошёл
return True
def make_format_checker():
checker = FormatChecker()
checker.checks("date-time", raises=ValueError)(_check_date_time)
return checker
def short_path(error):
path = "/".join(str(p) for p in error.absolute_path)
return f"{path or '<root>'}: {error.message[:120]}"
def main():
failed = False
validators = {}
format_checker = make_format_checker()
for kind, path in SCHEMAS.items():
if not path.is_file():
print(f"FAIL схема {path.name}: файл не найден")
failed = True
continue
try:
schema = json.loads(path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except Exception as exc: # битый JSON или нарушение метасхемы
print(f"FAIL схема {path.name}: {str(exc).splitlines()[0][:150]}")
failed = True
continue
validators[kind] = Draft202012Validator(schema, format_checker=format_checker)
print(f"OK схема {path.name}")
counts = {kind: 0 for kind in SCHEMAS}
for example in sorted(EXAMPLES.glob("*.json")):
kind = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None)
if kind is None:
print(f"FAIL {example.name}: имя не подходит ни под один шаблон (request-*, result-message-*, result-*)")
failed = True
continue
counts[kind] += 1
validator = validators.get(kind)
if validator is None:
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: схема недоступна")
failed = True
continue
try:
instance = json.loads(example.read_text(encoding="utf-8"))
except ValueError as exc:
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: не JSON: {exc}")
failed = True
continue
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path))
if errors:
failed = True
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]")
for error in errors:
print(f" {short_path(error)}")
else:
print(f"OK {example.name} [{SCHEMAS[kind].name}]")
for kind, count in counts.items():
if count == 0:
print(f"FAIL нет ни одного примера для схемы {SCHEMAS[kind].name}")
failed = True
total = sum(counts.values())
print(f"Итого: примеров {total}, {'есть ошибки' if failed else 'всё валидно'}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())