docs: §1.3/§1.5/§3/§9 по факту маппинга, архитектура (цикл маппинга), OQ, план срезов, qa-guide
05-domain-rules: §1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 (загрузка, маппинг, автозамена, подтверждение, удаление, аудит). 03-architecture: цикл маппинга, порядок блокировок, схема ключей S3. 07-open-questions: закрыты OQ-67/69/70/59/109, новые OQ-110…116. 09-delivery-plan: Срез 2c по факту. dev/qa-guide: раздел 6.6 «Загрузка ОСВ и просмотр строк через API». tasks.md: 2.1 выполнен. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
5545ee539f
commit
45ee08c0f8
@ -1,4 +1,4 @@
|
||||
# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.14)
|
||||
# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.15)
|
||||
|
||||
## Компоненты (= репозитории, см. `00-workspace-layout.md`)
|
||||
|
||||
@ -87,6 +87,32 @@
|
||||
- Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв
|
||||
контрагентов, проверки — в ядре.
|
||||
|
||||
### Цикл маппинга результата и порядок блокировок (Срез 2c)
|
||||
|
||||
Обработка результата разделена на два шага, чтобы подписка NATS не ждала S3 и тяжёлые вставки (иначе ack следующих
|
||||
сообщений не успевает за `ACK_TIMEOUT_SECONDS`, OQ-69):
|
||||
|
||||
- **Inbox** (`handle_message`, в callback подписки) — быстрый: дедуп по `result_msg_id`, регистрация файла результата в
|
||||
`s3_object`, статус задания (`done` / `needs_check` / `error`), статус файла по заданию (`PROCESSING`/`ERROR`). Файл результата **не читает**
|
||||
и документов не создаёт.
|
||||
- **Цикл маппинга** (`services/result_mapping.py:run_mapping_loop`, фоновая задача lifespan наравне с публикатором outbox и
|
||||
вахтёром сроков — всего **три цикла**; период `MAPPING_POLL_SECONDS` = 5) — берёт кандидата `done`/`needs_check`, чей файл
|
||||
`PROCESSING` и для которого это текущее задание (`FOR UPDATE SKIP LOCKED`), проверяет ключ результата (бакет хранилища и
|
||||
префикс `results/{file_id}/{job_id}/`, OQ-70), читает объект из S3 **вне транзакции**, валидирует `RecognitionResult` v1.x
|
||||
и в одной транзакции создаёт документы и строки, переводит файл в `PROCESSED` и выполняет автозамену. Повторяемые
|
||||
сбои — до `MAPPING_MAX_ATTEMPTS` (счётчик в `recognition_job.metrics.mapping_attempts`), затем файл `ERROR`
|
||||
(`metrics.mapping_error`). Алгоритм — `05-domain-rules.md` §9.5; чистые функции — `domain/osv_mapping.py`.
|
||||
|
||||
**Статус файла** (`stored_file.status`) меняет только код, проверивший `current_recognition_job_id == job.id`
|
||||
(`services/file_status.py`); результат устаревшего задания файл не трогает; `PROCESSED`/`SUPERSEDED` обменом не
|
||||
перезаписываются.
|
||||
|
||||
**Порядок блокировок: задание → файл → документы.** Его соблюдают все участники: inbox, вахтёр, публикатор (`dlq`), цикл
|
||||
маппинга (после выбора кандидата блокирует файл), `confirm` (файл, затем документ, оба перечитываются под `FOR UPDATE`) и
|
||||
`DELETE /files/{id}` (все задания файла по `id`, затем файл и документы). Автозамена одного ЮЛ дополнительно берёт
|
||||
`pg_advisory_xact_lock` по ЮЛ (иначе два параллельно смапленных файла с одним документом не увидят друг друга), загрузка —
|
||||
advisory-lock по `(ЮЛ, sha256)` (двойной клик не проскакивает дедуп).
|
||||
|
||||
## Схема ключей S3
|
||||
|
||||
**Один бакет** (`OPENBAO__MINIO__MINIO_BUCKET` шаблона); роль объекта — первый сегмент ключа:
|
||||
@ -95,6 +121,12 @@
|
||||
в `s3_object` (`bucket_role`, `bucket`, `key`, sha256, size, expires_at). Сырой ответ OCR `raw/{job_id}.json` — по
|
||||
конвенции, без записи в БД. Lifecycle-правила — по префиксам.
|
||||
|
||||
Срез 2c: ядро кладёт исходник по ключу `uploads/{file_id}/{job_id}/{безопасное имя}` (`job_id` задаётся заранее — ключ
|
||||
известен до записи в БД; имя очищается от путей и управляющих символов); `results/{file_id}/{job_id}/…` пишет сервис
|
||||
распознавания, а ядро перед чтением проверяет, что ключ лежит именно в папке задания (OQ-70). При удалении файла объектам
|
||||
`uploads` и `results` проставляется `s3_object.expires_at = now()`, физически они не стираются (ретеншн — OQ-111, ждёт
|
||||
lifecycle-правил платформы); объект, записанный в S3 до сбоя БД, остаётся сиротой (то же правило).
|
||||
|
||||
## Аутентификация и авторизация
|
||||
|
||||
1. SPA получает JWT от РАИСА; каждый запрос несёт `Authorization: Bearer`.
|
||||
@ -120,7 +152,7 @@ api/src/
|
||||
# машины статусов, policy — pydantic-модели на входе и выходе
|
||||
services/ # use-cases: оркестрация domain + repositories + outbox + audit_event
|
||||
repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка)
|
||||
models/ # ORM 1:1 с DBML v5.14; имена таблиц/колонок/enum — как в DBML
|
||||
models/ # ORM 1:1 с DBML v5.15; имена таблиц/колонок/enum — как в DBML
|
||||
schemas/ # pydantic DTO API /api/v1
|
||||
core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент),
|
||||
# logger, exception_handlers
|
||||
@ -205,7 +237,7 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
|
||||
`version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`.
|
||||
- Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное
|
||||
`<rev>_<slug>.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`.
|
||||
- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.14.
|
||||
- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.15.
|
||||
|
||||
### Инфраструктурные клиенты
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# 05. Правила предметной области (выжимка из ТЗ, имена — v5.14)
|
||||
# 05. Правила предметной области (выжимка из ТЗ, имена — v5.15)
|
||||
|
||||
Каждый раздел: источник в ТЗ → правило → где в модели. Где ТЗ молчит или противоречит — пометка **[открыто]** и
|
||||
ссылка на `07-open-questions.md`.
|
||||
@ -120,13 +120,20 @@ r11 Договоры ←
|
||||
(после последнего ACCOUNT). Увеличение уровня — ребёнок, равный — сосед, уменьшение — конец группы.
|
||||
3. После COUNTERPARTY дочерние продолжают классифицироваться по схеме (CONTRACT, затем OTHER).
|
||||
4. Строка «Итого» (level 0, последняя) — контрольная сумма всего документа; не сохранять как `osv_entry`
|
||||
**[открыто №2]**, но использовать для сверки. Парсер отдаёт её в `rows` как `group_type = TOTAL` (level 0, без
|
||||
родителя; `ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро — при маппинге строку TOTAL отбросить); в
|
||||
(OQ-01 закрыт). Парсер отдаёт её в `rows` как `group_type = TOTAL` (level 0, без родителя); **ядро (Срез 2c)
|
||||
при маппинге строку TOTAL отбрасывает** (`osv_entry` её не содержит, `TOTAL` — не значение хранимого `group_type`); в
|
||||
`metrics.rows` она не считается.
|
||||
5. Все остальные строки сохраняются (п. 11 «Парсинг ОСВ») с `parent_entry_id` **[открыто №2]**.
|
||||
5. Все остальные строки сохраняются (п. 11 «Парсинг ОСВ») с `parent_entry_id` по `parent_row_number` (OQ-01 закрыт):
|
||||
дерево строится по номеру родителя, строка с несуществующим родителем сохраняется с `parent_entry_id = NULL` и даёт
|
||||
предупреждение маппинга (документ `NEEDS_CHECK`). Суммы и `data_type` (включая `NU`) пишутся как в результате.
|
||||
|
||||
Нормализация кода счёта: `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1`; `account_root` = первые две-три цифры
|
||||
(`001` — забалансовый). `account_id` = точное совпадение с `chart_of_account.code`, иначе NULL (администратор добавит).
|
||||
Нормализация кода счёта (ядро, `domain/osv_mapping.py`): `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1` —
|
||||
числовой **второй** сегмент (1–3 цифры) дополняется нулём слева до двух цифр, первый и третий сегменты и буквенные
|
||||
(`76.АВ`, `МЦ.04`) остаются как в файле. `account_code` строки хранится как в результате (`account_code_raw`),
|
||||
нормализация нужна для `document.osv_account_code` и сверки с планом счетов. `account_root` = первый сегмент кода, если он
|
||||
цифровой (`66`, `001` — забалансовый), иначе NULL (`ASSUMPTION(OQ-103)`). `account_id` = точное совпадение
|
||||
нормализованного кода с `chart_of_account.code`, иначе NULL (администратор добавит; в seed плана счетов нет части
|
||||
субсчетов — OQ-113).
|
||||
|
||||
### 1.4 Контрольные суммы парсера
|
||||
|
||||
@ -181,7 +188,14 @@ r11 Договоры ←
|
||||
ОСВ «за 2024 г.» — годовые. Признак можно проверить по данным: если есть ОСВ Q1 того же года и
|
||||
`Q3.opening == Q1.opening` → нарастающая.
|
||||
|
||||
Сверка периода документа с периодом версии → `document.period_check` (MATCH / MISMATCH / UNKNOWN); загрузка не блокируется.
|
||||
**Сверка по данным (ядро, Срез 2c, `ASSUMPTION(OQ-02)`).** Для документа с `period_quarter ∈ {Q2, Q3, Q4}` и
|
||||
`is_cumulative = false` маппер берёт документ `Q1` того же ЮЛ, года и счёта (файл не `SUPERSEDED`; при нескольких —
|
||||
последний по `created_at`) и сравнивает сальдо на начало корневой строки листа (дебет и кредит, `NULL` = 0, допуск ±0.01
|
||||
включительно). Совпало → `is_cumulative = true` и предупреждение маппинга «нарастающая по данным Q1»; предупреждение
|
||||
делает документ `NEEDS_CHECK` (§9.3, OQ-116). Нет документа Q1 или корня — признак остаётся по тексту периода.
|
||||
|
||||
Сверка периода документа с периодом версии → `document.period_check` (MATCH / MISMATCH / UNKNOWN); загрузка не блокируется;
|
||||
до появления версии консолидации (Срез 4) ядро пишет `UNKNOWN`.
|
||||
|
||||
### 1.6 Результат парсера (Срез 2a): статусы, коды, лимиты, словари
|
||||
|
||||
@ -245,10 +259,15 @@ closing_* = closing_* последней ОСВ (Qn)
|
||||
2. Строки `osv_entry` этих документов с `group_type = COUNTERPARTY`; `data_type IN (BU, SUM)`.
|
||||
3. Строки **не агрегируются**: сто строк по 60.01 у компании — сто строк в своде. `counterparty_name/inn` показываются
|
||||
как в файле.
|
||||
4. Резолв контрагента (при маппинге результата и повторно при построении свода, если `counterparty_id IS NULL`):
|
||||
4. Резолв контрагента (при маппинге результата — Срез 2c, и повторно при построении свода, если `counterparty_id IS NULL`):
|
||||
ИНН → `legal_entity.inn`; иначе точное имя → `legal_entity.name`; иначе `normalize(name)` →
|
||||
`legal_entity_alias.normalized_alias`. Несколько кандидатов → NULL (ручное сопоставление).
|
||||
`normalize` = lower, trim, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы.
|
||||
Реализация при маппинге (`repositories/counterparties.py:CounterpartyIndex`): справочник читается целиком **без policy
|
||||
видимости** (контрагент может быть ЮЛ другого филиала); применяется к строкам `group_type = COUNTERPARTY` с
|
||||
`data_type ∈ {BU, SUM}`; ИНН строки сравнивается точно после удаления пробелов (мусор вроде «нерез.» не ищется, строка
|
||||
проваливается к имени); каждый шаг считает кандидатов отдельно: единственное точное совпадение имени побеждает
|
||||
неоднозначность нормализованного; имя из одних ОПФ (пусто после `normalize`) не ищется.
|
||||
5. Признак контрагента: ГСК / ГК / Внешний (см. `04-data-model-guide.md`).
|
||||
6. Количество строк свода = количеству отобранных `osv_entry`.
|
||||
|
||||
@ -418,15 +437,60 @@ NEEDS_CHECK`; пройдено/акцептовано + нажато «Подт
|
||||
Каждый переход → `consolidation_status_history` + `audit_event(CONSOLIDATION_STATUS_CHANGE)`.
|
||||
|
||||
### 9.2 Файл (`file_status`): UPLOADED → PROCESSING → PROCESSED | ERROR; любой → SUPERSEDED (замена файлом целиком,
|
||||
`superseded_by_file_id`, `version_no + 1`). Физическое удаление после INITIATED запрещено (ANL-020) — только замена.
|
||||
`superseded_by_file_id`, `version_no + 1`). Статус меняют только по **текущему** заданию файла
|
||||
(`current_recognition_job_id`): `PROCESSING` — ack сервиса (или результат `done`/`needs_check`, если ack потерян);
|
||||
`ERROR` — задание `error`/`timeout`/`dlq` либо ошибка маппинга; `PROCESSED` — маппинг завершён; `SUPERSEDED` — автозамена.
|
||||
`PROCESSED` и `SUPERSEDED` событиями обмена не перезаписываются. Физическое удаление: ANL-020 запрещает его после
|
||||
INITIATED; в Срезе 2c консолидаций ещё нет, поэтому удаление разрешено, кроме файла с `CONFIRMED` документом и файла,
|
||||
заменившего другие (§9.5); поведение после появления консолидаций — OQ-110.
|
||||
|
||||
### 9.3 Документ (`document_status`): создаётся ядром при маппинге результата: все значения `confidence = 100`, проверки
|
||||
пройдены, период и контрагент совпали → RECOGNIZED [открыто: нужен ли], иначе NEEDS_CHECK; «Подтвердить данные» → CONFIRMED;
|
||||
не разобран (нет шапки) → ERROR.
|
||||
### 9.3 Документ (`document_status`): создаётся ядром при маппинге результата, по одному на лист ОСВ:
|
||||
`RECOGNIZED` — задание `done`, `checks` парсера без нарушений, `counterparty_check ≠ MISMATCH` и нет предупреждений
|
||||
маппинга **этого листа** (повтор счёта в файле, строки без родителя, нарастающая по данным Q1); иначе `NEEDS_CHECK`
|
||||
(OQ-05 закрыт: `RECOGNIZED` нужен); «Подтвердить данные» → `CONFIRMED`; `ERROR` — зарезервирован (в Срезе 2c ядро
|
||||
документ в `ERROR` не создаёт: файл без разбираемой ОСВ получает `ERROR` на уровне файла). `counterparty_check`: ИНН шапки
|
||||
равен ИНН владельца файла → `MATCH`; ИНН нет — нормализованное имя шапки совпало с именем или алиасом владельца → `MATCH`;
|
||||
иначе `MISMATCH`; пустая шапка → `UNKNOWN`. `period_check` — `UNKNOWN` до Среза 4.
|
||||
|
||||
### 9.4 Задание (`recognition_status`): queued → published → processing → done | needs_check | error | timeout → dlq;
|
||||
replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `reply_deadline` OCR 5 мин.
|
||||
|
||||
### 9.5 Первичные документы: загрузка, маппинг, замена, удаление, подтверждение (Срез 2c)
|
||||
|
||||
1. **Загрузка** (`POST /legal-entities/{id}/files`, право `DOCUMENT_EDIT`, ЮЛ в видимости): тип файла по расширению и
|
||||
сигнатуре (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`, `jpg`); лимит `UPLOAD_MAX_BYTES` (50 МиБ); дедуп по
|
||||
`sha256` в пределах ЮЛ среди файлов не `SUPERSEDED`. Принятый файл: объект S3 `uploads/{file_id}/{job_id}/{имя}`,
|
||||
`stored_file(UPLOADED, version_no = 1)`, `recognition_job(queued)`, `audit_event(FILE_UPLOAD)`. Тип документа и период
|
||||
при загрузке не запрашиваются: их определяет парсер.
|
||||
2. **Маппинг** (фоновый цикл ядра, отдельный от inbox): задание `done`/`needs_check` + файл `PROCESSING` → проверка ключа
|
||||
результата (бакет и префикс `results/{file_id}/{job_id}/`, OQ-70) → чтение S3 вне транзакции → одна транзакция:
|
||||
прежние строки файла удаляются, на каждый лист ОСВ создаётся `document` (`osv_account_code` — нормализованный счёт
|
||||
шапки, `period_year/quarter`, `is_cumulative` по §1.5, `locator`), вставляются `osv_entry` (§1.3), резолвится
|
||||
`counterparty_id` (§3 п. 4), пишутся `recognition_job.group_schema` и `metrics.mapping_warnings`, файл → `PROCESSED`,
|
||||
выполняется автозамена. Второй лист того же файла с тем же счётом документа не создаёт (предупреждение, первый
|
||||
документ — `NEEDS_CHECK`). Повторяемые сбои (БД, S3) — до `MAPPING_MAX_ATTEMPTS` (3) попыток, затем файл `ERROR`
|
||||
(`metrics.mapping_error`); неповторяемые (ключ вне папки, нет объекта, результат другого задания, нет листов ОСВ) —
|
||||
сразу `ERROR`. Документы типов ББ/ОФР/прочее в результате игнорируются (Срез 2b/3).
|
||||
3. **Автозамена.** После успешного маппинга другие файлы того же ЮЛ (не `SUPERSEDED`), содержащие документ с теми же
|
||||
`document_type`, `osv_account_code`, `period_year`, `period_quarter`, переводятся в `SUPERSEDED`
|
||||
(`superseded_by_file_id` = новый файл); `version_no` нового = максимум заменённых + 1. Заменяется и файл с `CONFIRMED`
|
||||
документом (решение владельца 05.10.2026); документ без года или квартала в замене не участвует. Документы и строки
|
||||
заменённых файлов сохраняются, но по умолчанию не показываются. Если новый файл содержит лишь часть документов
|
||||
заменяемого, заменяемый уходит в `SUPERSEDED` целиком (OQ-06: замена файлом целиком; частный случай — OQ-115).
|
||||
Аудит: системное `FILE_REPLACE` (`user_id = NULL`, `object_id` — заменённый файл, `after_value` — новый, в `description`
|
||||
перечень документов и `confirmed=true`).
|
||||
4. **Подтверждение** (`POST /documents/{id}/confirm`, `DOCUMENT_EDIT`): `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`
|
||||
(`confirmed_by/at`, `audit_event(DOCUMENT_CONFIRM)`); повтор — 409 `ALREADY_CONFIRMED`; документ заменённого файла —
|
||||
409 `FILE_SUPERSEDED`; прочие статусы — 409 `DOCUMENT_NOT_CONFIRMABLE`. Правка строк и корректировки — подсрез 2d/Срез 3.
|
||||
5. **Удаление** (`DELETE /files/{id}`, `DOCUMENT_EDIT`): запрещено при `CONFIRMED` документе
|
||||
(`FILE_HAS_CONFIRMED_DOCUMENTS`) и если файл заменил другие (`FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить
|
||||
можно. Удаляются строки, документы, задания и `stored_file`; объектам S3 ставится `expires_at = now()` (физически не
|
||||
стираются — OQ-111); аудит `FILE_DELETE` с карточкой файла в `before_value`.
|
||||
6. **Аудит.** `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`); смены статусов
|
||||
задания/файла в обмене и в цикле маппинга `audit_event` не пишут (OQ-55).
|
||||
7. **Порядок блокировок** (против взаимоблокировок): задание → файл → документы; автозамена дополнительно берёт
|
||||
advisory-lock по ЮЛ. Подробности — `03-architecture.md`.
|
||||
|
||||
---
|
||||
|
||||
## §10. Роли и матрица прав (ТЗ 8.1, DOC-036960) — seed для `role_permission`
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
| 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-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 | **закрыт реализацией Среза 2c (06.10.2026, `arm-core/openspec/changes/slice-2c-osv-ingest`), иначе, чем предлагалось:** индекс по `lower(email)` не добавлен (сверка ORM↔DBML не умеет expression-индексы); в DBML v5.15 `app_user.email` хранится в нижнем регистре (миграция `0003` приводит существующие, seed и администрирование нормализуют при записи), поэтому обычный UNIQUE регистронезависим по построению |
|
||||
| OQ-60 | Seed `--dev` не защищён флагом `DEBUG`: создаёт dev-пользователей (в том числе `ADMIN`) на любой БД, на которую указывает `.env`, и перезаписывает существующего `app_user` с совпадающим email. Нужна ли защита? | arm-core `scripts/seed.py`; arm-core: предл. 13 (T15.2, T15.4) | срез 0 (процесс); прод-развёртывание | **закрыт реализацией (arm-core PR #10, техдолг Среза 1, 05.10.2026):** `python -m scripts.seed --dev` при `DEBUG=false` отказывает с кодом 2 до чтения seed и подключения к БД; `run_seed(dev=True)` для тестов без защиты |
|
||||
| OQ-61 | Неверный метод на существующий API-маршрут (например `POST /api/v1/me`) отдаёт 404 вместо 405: SPA-монтирование на `/` перехватывает частичное совпадение, а `/api/*` в нём принудительно 404. Приемлемо? | arm-core `app.py` (`SPAStaticFiles`); arm-core: предл. 14 (T18.2) | API-контракт (клиенты, фронт) | **закрыт реализацией (arm-core PR #10, техдолг Среза 1, 05.10.2026):** неверный метод на существующий маршрут `/api/v1/*` — 405 с заголовком `Allow` («метод не поддерживается этим маршрутом»); несуществующий путь и dev-маршрут при выключенных флагах — 404 |
|
||||
| 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 (фронт) | **частично закрыт (arm-core PR #10):** редирект `/` → `//` устранён (причина — `root_path="/"`, теперь нормализуется), `GET /` отдаёт `index.html`. **Открыто:** фронт не собирается в образ `arm-core` — follow-up RSHB/arm-core#12 |
|
||||
@ -72,10 +72,10 @@
|
||||
| OQ-64 | Аудит `LOGIN` / `LOGIN_FAILED` (действия есть в `audit_action`) для JWT-аутентификации: в каком срезе и что считать «входом» — первый запрос с токеном, каждый запрос, отказ проверки токена? | DBML `audit_action`; `core/auth.py`; arm-core: предл. 16 (финальное ревью) | срез 1 (вход, админ-экран); аудит | **закрыт реализацией (arm-core PR #10, техдолг Среза 1, 05.10.2026):** `LOGIN` — выдача dev-токена и первый успешный запрос пользователя с токеном после старта процесса; `LOGIN_FAILED` — 401 (токен не принят, без `user_id`, причина без токена) и 403 аутентификации (email в `description`); раздел `SESSION`, отдельная транзакция; запрос без `Authorization`/не Bearer и 403 по праву — не события входа; `LOGOUT` не пишется (сессий на сервере нет) |
|
||||
| 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 (финальное ревью) | прод-конфигурация; достоверность аудита | **частично закрыт (arm-core PR #10):** CORS при `DEBUG=true` — `*` без credentials, при `DEBUG=false` — `CORS_ORIGINS` (пусто — кросс-доменные запрещены); `X-Forwarded-For` только от `FORWARDED_ALLOW_IPS` (по умолчанию `127.0.0.1`). **Открыто (к платформе):** перечень доверенных прокси прода — follow-up RSHB/arm-core#12 |
|
||||
| OQ-66 | `/docs` и `/openapi.json` FastAPI открыты без аутентификации, в том числе в проде. Закрывать их (выключать при `DEBUG=false`) или оставить? | arm-core `app.py` (шаблон); arm-core: предл. 18 (финальное ревью) | прод-конфигурация; безопасность | **закрыт реализацией (arm-core PR #10, техдолг Среза 1, 05.10.2026):** `/docs`, `/redoc`, `/openapi.json` существуют только при `DEBUG=true`; в проде — 404 |
|
||||
| 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-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 (загрузка и маппинг) | **закрыт реализацией Среза 2c (06.10.2026, `arm-core/openspec/changes/slice-2c-osv-ingest`):** статус файла меняет код, проверивший `current_recognition_job_id == job.id` (`services/file_status.py`): ack/результат — `PROCESSING`, `error`/`timeout`/`dlq` — `ERROR` (inbox, вахтёр, публикатор), успешный маппинг — `PROCESSED`, автозамена — `SUPERSEDED`; `current_recognition_job_id` ставит загрузка. См. `05-domain-rules.md` §9.2, §9.5 |
|
||||
| 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-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) | **закрыт реализацией Среза 2c (06.10.2026, `arm-core/openspec/changes/slice-2c-osv-ingest`):** inbox быстрый (фиксация сообщения и статусов, файл результата не читает), маппинг — отдельный фоновый цикл `run_mapping_loop` (`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); ack за `ACK_TIMEOUT_SECONDS` не задерживается. См. `03-architecture.md`, «Цикл маппинга результата» |
|
||||
| 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 | **закрыт реализацией Среза 2c (06.10.2026, `arm-core/openspec/changes/slice-2c-osv-ingest`):** цикл маппинга отвергает результат, если бакет не бакет хранилища или ключ не начинается с `results/{file_id}/{job_id}/` (без `.`/`..`/пустых сегментов) — файл `ERROR`, повтора нет; правило закреплено в `03-architecture.md` («Схема ключей S3») |
|
||||
| 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 не рассмотрен — вопрос остаётся открытым** |
|
||||
@ -114,7 +114,14 @@
|
||||
| OQ-106 | «Итого = корень» сверяется с **первой** корневой строкой ACCOUNT (уровень 0). Лист с несколькими корнями уровня 0 (несколько счетов в одной ОСВ; ОСВ по субконто без строк-счетов) получает `needs_check`, хотя «Итого» может быть равна Σ корней. Сверять «Итого» с суммой корней? | tabular-parser: финальное ревью Среза 2a, M-4 | срез 2a+ (контрольные суммы `checks.py`), чек-лист документов | допущение принято: сверка с первым корнем; многокорневой лист → `needs_check` (аналитик проверяет вручную); в фикстурах таких листов нет |
|
||||
| OQ-107 | При `hierarchy=sums` (дерево восстановлено по равенству «сумма детей = родитель») сверка «сумма детей = родитель» тавтологична — она не может не сойтись. Считать ли такой разбор `needs_check` (или отдельное предупреждение достаточно)? | tabular-parser: финальное ревью Среза 2a, M-5 | срез 2a+ (статус результата), срез 2c (UI проверки) | допущение принято: статус — по контрольным суммам, стратегия видна в `metrics.warnings` (`hierarchy=sums`); на реальных файлах стратегия не выбирается (все — `outline`/`trivial`) |
|
||||
| OQ-108 | Защита фазы чтения xlsx от XML-бомб (entity expansion) — `defusedxml` как новая зависимость сервиса — и иные лимиты памяти чтения. Остаточные риски после R-23/R-25: (1) бомба по объёму самих ячеек в пределах лимита распакованного zip — xlsx в 5 МБ с 2 млн обычных ячеек читается openpyxl 13 с и занимает 1,5 ГБ (замер ревьюера); (2) объём `sharedStrings` и стилей в тех же пределах; (3) entity expansion (от экспоненты «billion laughs» защищает только сам expat ≥ 2.4); (4) **известный обход предсканирования (R-26):** лист в части без `.xml` с ≥ 64 байтами ведущих пробелов и без XML-декларации не попадает в скан (файл 1,8 КБ → 27 с / 1,4 ГБ в openpyxl) — первый follow-up: убрать «sniff» и отдавать expat каждую некаталожную запись; (5) скан сам не ограничен размером чанка: «мусорная» XML-часть с атрибутом в 400 МБ → 1,3 ГБ / 1,2 с, 400 МБ тегов → 8,5 с CPU; (6) xls с очень многими крупными merged-диапазонами (> 10 000 ячеек каждый) — линейный просмотр индекса `Sheet` (≈ 4,4 мс на обращение), останавливает только `PARSE_TIMEOUT_SECONDS`: потолок на число таких диапазонов. Предскан — эшелон защиты, не граница безопасности; настоящая граница — лимит памяти контейнера / подпроцесс с RLIMIT. Рекомендация: жёсткий лимит памяти контейнера сервиса; и/или предпроверка объявленного `<dimension>` и потоковый счётчик ячеек `<c>`/строк `sharedStrings` (тем же expat-сканом, что объединения) с лимитом; и/или разбор книги в подпроцессе с `RLIMIT_AS`/таймаутом, который можно убить. Решение платформы | tabular-parser: финальное ревью Среза 2a, I-1 (хвост), R-23, R-25 | tabular-parser (readers), платформа (зависимости и лимиты образа/пода) | допущение принято: действуют лимиты R-23/R-25 — распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек книги ≤ 200 000 (потоковый expat-скан всех XML-частей до openpyxl); объём ячеек, `sharedStrings`/стили и entity expansion не ограничены (README «Известные ограничения») |
|
||||
| OQ-109 | Воркер tabular-parser скачивает объект S3 целиком и только потом проверяет `PARSE_MAX_BYTES`; проверять размер по `stat` до скачивания | tabular-parser: финальное ревью Среза 2a, M-7 | tabular-parser (`core/worker.py`) — вне Среза 2a | открыт: правка воркера — follow-up; лимит размера сейчас срабатывает после скачивания (память под файл выделяется) |
|
||||
| OQ-109 | Воркер tabular-parser скачивает объект S3 целиком и только потом проверяет `PARSE_MAX_BYTES`; проверять размер по `stat` до скачивания | tabular-parser: финальное ревью Среза 2a, M-7 | tabular-parser (`core/worker.py`) — вне Среза 2a | **закрыт реализацией Среза 2c (06.10.2026, `arm-core/openspec/changes/slice-2c-osv-ingest`):** ядро ограничивает размер файла на загрузке (`UPLOAD_MAX_BYTES` = 50 МиБ, как `PARSE_MAX_BYTES` парсера), поэтому в воркер большие объекты из ядра не попадают; проверка по `stat` до скачивания в `tabular-parser` остаётся как есть (дефенсивная, не требуется) |
|
||||
| OQ-110 | Удаление файла после консолидаций: у `document` появятся FK из `financial_statement_meta`, `consolidation_document`, `ex_snapshot_item`, а `DELETE /files/{id}` Среза 2c их не чистит (в 2c строк в этих таблицах нет). ANL-020 запрещает физическое удаление после INITIATED. Что делать: запретить удаление файла, документ которого входит в версию консолидации, и/или каскадно чистить служебные таблицы (`financial_statement_meta` — в Срезе 3) | arm-core Срез 2c: Task 10 (R-23, design №36, №48) | Срезы 3, 4 (расширить удаление или запрет) | открыт: в 2c сделана только проверка `CONFIRMED`/`superseded_by`; без расширения удаление файла с документом в версии даст IntegrityError (500) |
|
||||
| OQ-111 | Ретеншн S3-объектов при удалении файла: сейчас `DELETE` лишь ставит `s3_object.expires_at = now()`, объекты `uploads`/`results` физически не стираются (дополняет OQ-22). Кто и когда их удаляет — lifecycle-правило бакета по метаданным, фоновая задача ядра, ручная очистка? Объект, записанный в S3 до сбоя БД, остаётся сиротой | arm-core Срез 2c: design D-10, №36 | платформа (lifecycle), Срез 2 → эксплуатация | допущение принято: бакет растёт; очистка — вне среза |
|
||||
| OQ-112 | Остаточные объекты e2e-прогона: каждый запуск `make e2e` оставляет в MinIO dev-стенда ~2 объекта (`uploads/…`, `results/…` — следствие OQ-111) | arm-core Срез 2c: Task 11 (O-5) | dev-стенд | допущение принято: чистить бакет `arm-ka` вручную (раздел «Сброс стенда» `dev/qa-guide.md`) |
|
||||
| OQ-113 | Полнота плана счетов seed: 19 нормализованных кодов строк-счетов golden ОСВ отсутствуют в `chart_of_account` (`01.01`, `66.01`, `76.01`, `60.АВ`, `76.ОТ` и др.), поэтому у их строк `account_id = NULL`. Достаточно ли «администратор добавит» или seed должен включать полный набор субсчетов (включая буквенные) до Среза ВГО (§4–§5) | arm-core Срез 2c: Task 8 (O-3) | Срезы 4–5 (пары счетов элиминации) | открыт: допущение — `account_id = NULL` легален (§1.3), `account_code`/`account_root` заполнены |
|
||||
| OQ-114 | `EntryOut` (API строк ОСВ) не отдаёт `is_modified`/`original_*`: панели правки подсреза 2d нужен признак правки аналитика (инвариант «первичка не мутируется»: правка — рядом, с аудитом). Добавить поля в схему и контракт API при проектировании 2d | arm-core Срез 2c: Task 9 (T9.7) | подсрез 2d | открыт: в 2c строк не правят, `is_modified = false` у всех |
|
||||
| OQ-115 | Автозамена: если новый файл содержит лишь часть документов прежнего (прежний — ОСВ 60 и 51, новый — только 60), прежний уходит в `SUPERSEDED` **целиком**, документ 51 пропадает из списка файлов; в `FILE_REPLACE.description` о нём ничего нет. Подтвердить «замена файлом целиком» (OQ-06) для этого случая или заменять по документам | arm-core Срез 2c: Task 8 (T8.4) | Срез 2d (список файлов), Срез 4 (комплект документов) | допущение принято: замена файлом целиком, документы заменённого файла сохраняются в БД |
|
||||
| OQ-116 | ОСВ Q2–Q4, признанная нарастающей по данным Q1 (сальдо на начало корня равно сальдо Q1, §1.5), получает предупреждение маппинга и поэтому остаётся `NEEDS_CHECK`, а не `RECOGNIZED` (буквально D-7/D-9 спеки: `RECOGNIZED` только без предупреждений). Верно ли, что аналитик подтверждает такие документы вручную, или нарастающая по данным — штатный случай без предупреждения (OQ-02, OQ-05) | arm-core Срез 2c: Task 7 (R-17, design №30) | Срез 2d (нагрузка на аналитика), §9.3 | допущение принято: `NEEDS_CHECK`; снять предупреждение — одна правка в `result_mapping.py` и правило статуса |
|
||||
|
||||
Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения;
|
||||
`DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4.
|
||||
|
||||
@ -114,14 +114,38 @@ Change'и: `tabular-parser/openspec/changes/slice-2a-osv-parser` (issue arm-tabu
|
||||
- Тесты: property-тесты инвариантов на **всех** файлах `fixtures/statements-xlsx/**` (формулы §7) + синтетика (ББ+ОФР на
|
||||
одном листе, csv-подобный лист); снимки результата — рядом с golden ОСВ.
|
||||
|
||||
### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`)
|
||||
### Срез 2c — Загрузка файлов и маппинг результата в ядре (`arm-core`) — реализован, ожидает ревью и merge
|
||||
|
||||
- `arm-core`: `stored_file` + `s3_object` + `recognition_job` (outbox) + дедуп по `content_hash` + замена файла (`SUPERSEDED`).
|
||||
- Маппинг результата в ядре: `document` (OSV) + `osv_entry` (все строки, OQ-01) + резолв `account_id`, `account_root`,
|
||||
`counterparty_id` по ИНН/имени/алиасу; `document.period_check / counterparty_check`; статусы. Решения, отложенные
|
||||
парсером: OQ-01 (хранение «Итого»), OQ-02 (нарастающие по данным), OQ-45 (`NU`), OQ-67, OQ-69, OQ-70 (маппер и
|
||||
`recognition.result`), OQ-100 (ИНН из текста имени контрагента).
|
||||
- Маппинг ББ/ОФР в ядре из `statementDocument` — общий для Excel и OCR (Срез 3 его переиспользует).
|
||||
Change'и: `arm-core/openspec/changes/slice-2c-osv-ingest` (issue arm-core#14, ветка `feat/14-slice-2c-osv-ingest`), сквозной
|
||||
`docs/openspec/changes/slice-2c-osv-ingest` (ветка `feat/16-slice-2c-osv-ingest`, DBML v5.15).
|
||||
|
||||
**Сделано (по факту):**
|
||||
|
||||
- **Модель:** DBML v5.15 — `osv_data_type + NU` (налоговый учёт, OQ-45), `app_user.email` в нижнем регистре (закрывает OQ-59);
|
||||
миграция `0003_dbml_v5_15`, цикл `upgrade → downgrade → upgrade` и пустой autogenerate.
|
||||
- **Загрузка:** `POST /legal-entities/{id}/files` (xls/xlsx/xlsb/pdf/png/jpg, проверка сигнатуры, лимит 50 МиБ, дедуп по
|
||||
`sha256` в пределах ЮЛ, частичный успех `accepted`/`rejected`), S3 `uploads/{file_id}/{job_id}/…`, `stored_file` +
|
||||
`recognition_job` (outbox) + `FILE_UPLOAD`.
|
||||
- **Статусы файла** ведёт код по текущему заданию (`UPLOADED → PROCESSING → PROCESSED | ERROR`, `SUPERSEDED`): ack, результат,
|
||||
`error`, `timeout`, `dlq`; inbox результат не читает (OQ-67, OQ-69).
|
||||
- **Цикл маппинга** (третий фоновый цикл рядом с публикатором и вахтёром): проверка ключа результата (OQ-70), чтение S3
|
||||
вне транзакции, `document` на лист ОСВ, `osv_entry` без `TOTAL` с деревом, `account_root`/`account_id`, резолв
|
||||
`counterparty_id` по ИНН/имени/алиасу, `counterparty_check`, статус `RECOGNIZED`/`NEEDS_CHECK`, `is_cumulative` по данным
|
||||
Q1 (OQ-02), до 3 попыток; ОСВ_60 за 2024 (1034 строки) — 0,3 с.
|
||||
- **Автозамена версий** (`SUPERSEDED`, `version_no`, `FILE_REPLACE`; заменяется и `CONFIRMED`), **API** файлов/документов/строк,
|
||||
`POST /documents/{id}/confirm`, `DELETE /files/{id}` (запрет при `CONFIRMED` и при заменивших другие, `expires_at` у объектов S3).
|
||||
- **Проверки на реальных данных:** golden — все 82 снимка парсера (85 документов, 13 510 строк, ≈ 4 с) разобраны, инварианты
|
||||
спеки выполняются; сквозной прогон `make e2e` с реальным `tabular-parser` через compose (ОСВ_66 за 2024 → `PROCESSED`, 9
|
||||
строк). Итог `make check-full`: 900 unit, 437 integration, e2e 1, `-m fixtures` 13 (+1 e2e skip без профиля).
|
||||
- **Решения без обсуждения** — `arm-core/openspec/changes/slice-2c-osv-ingest/design.md`, разделы «Decisions made without
|
||||
discussion» (№1–50). Правила — `05-domain-rules.md` §1.3, §1.5, §3 п. 4, §9.2–9.5; архитектура — `03-architecture.md`
|
||||
(«Цикл маппинга результата и порядок блокировок»); QA — `dev/qa-guide.md` §6.6.
|
||||
- **Закрыты OQ:** 67, 69, 70, 59, 109. **Открыты новые:** OQ-110 (удаление файла после консолидаций), OQ-111 (ретеншн S3
|
||||
при удалении), OQ-112 (остаточные объекты e2e), OQ-113 (полнота субсчетов seed), OQ-114 (`is_modified` в `EntryOut`),
|
||||
OQ-115 (частичная замена файла), OQ-116 (нарастающая по Q1 → `NEEDS_CHECK`).
|
||||
- **Остаётся (Срез 2d и далее):** UI «Первичные документы»; маппинг ББ/ОФР (`statementDocument`) — общий для Excel и OCR (Срез 3
|
||||
его переиспользует); ручное сопоставление контрагента и пересчёт резолва при построении свода (Срезы 4–5); OQ-100 (ИНН из
|
||||
текста имени контрагента) по-прежнему не решён — резолв идёт по ИНН колонки, имени и алиасу.
|
||||
- Копия схемы контракта v1.1 в `arm-core` (`OsvRow.extra`) — если не сделана bounded-PR раньше.
|
||||
|
||||
### Срез 2d — UI «Первичные документы»
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
# Гайд тестировщика: локальный стенд АРМ КА (Срезы 0–1)
|
||||
# Гайд тестировщика: локальный стенд АРМ КА (Срезы 0–2c)
|
||||
|
||||
Документ для ручного тестирования текущего `main`. Путь проверен 05.10.2026 на macOS (Docker 28, compose 2.34,
|
||||
Node 24, uv 0.10): от пустого каталога до экрана администратора — около 15 минут, из них 10 — скачивание образов.
|
||||
|
||||
## 1. Что сейчас есть и чего нет
|
||||
|
||||
**Есть (Срез 0 + Срез 1):**
|
||||
**Есть (Срез 0 + Срез 1 + Срез 2c):**
|
||||
|
||||
- Бэкенд `arm-core` (FastAPI, `/api/v1`): вход по email в dev-режиме, профиль пользователя, права как данные.
|
||||
- API справочников: группы компаний (ГК), юрлица и ИП (ЮЛ) с историей, переводом между ГК и закрытием, алиасы
|
||||
@ -15,11 +15,14 @@ Node 24, uv 0.10): от пустого каталога до экрана адм
|
||||
- Администрирование: пользователи, роли, матрица «роль × право», защита последнего администратора.
|
||||
- Фронтенд: страница `/admin` («Пользователи и роли»). Другого UI нет.
|
||||
- Журнал аудита в БД (`dkb.audit_event`).
|
||||
- Загрузка файлов ОСВ через API (`POST /legal-entities/{id}/files`), распознавание реальным `tabular-parser`, разбор
|
||||
результата в документы и строки ОСВ, статусы файла и документа, автозамена версий, подтверждение документа, удаление
|
||||
файла (раздел 6.6). Всё — через Swagger/curl, UI «Первичные документы» — подсрез 2d.
|
||||
|
||||
**Нет (это не баги):**
|
||||
|
||||
- Экранов справочников ГК/ЮЛ/ГСК — проверяются только через Swagger (`/docs`) или curl.
|
||||
- Загрузки файлов отчётности, парсера ОСВ, распознавания PDF, консолидации, КФО — следующие срезы.
|
||||
- Экрана загрузки и таблицы ОСВ (подсрез 2d), ББ/ОФР, распознавания PDF, консолидации, КФО — следующие срезы.
|
||||
- Настоящего входа через SSO банка. Вход — dev-форма по email без пароля.
|
||||
|
||||
## 2. Что установить и проверить из ПО
|
||||
@ -87,6 +90,14 @@ cd api && .venv/bin/python run_server.py
|
||||
Ожидание: в логе нет трейсбэков; `curl -s localhost:8000/healthcheck` отвечает `{"uptime": ..., "nats": "ok"}`.
|
||||
Swagger: <http://localhost:8000/docs>. Сервер слушает только `127.0.0.1`, так и оставьте.
|
||||
|
||||
Для сценариев загрузки ОСВ (6.6) нужны ещё `NATS_ENABLED=true` (так в `api/.env.example`) и **tabular-parser**; поднимайте
|
||||
только его, без второго экземпляра ядра (он забирает часть сообщений `recognition.result`):
|
||||
|
||||
```bash
|
||||
docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build --no-deps tabular-parser
|
||||
curl -s localhost:8001/healthcheck # nats: ok
|
||||
```
|
||||
|
||||
### 4.3 Фронтенд (второй терминал)
|
||||
|
||||
```bash
|
||||
@ -240,6 +251,58 @@ docker compose -f ../docs/dev/docker-compose.yml exec postgres \
|
||||
| N3 | тело `PATCH` ГК с неизвестным полем | 422 (поля вне схемы запрещены) |
|
||||
| N4 | импорт файла больше 20 МБ | 413 |
|
||||
|
||||
### 6.6 Загрузка ОСВ и просмотр строк через API (Срез 2c)
|
||||
|
||||
Предусловия: раздел 4.2 (ядро с `NATS_ENABLED=true`, tabular-parser на `:8001`), файлы из `../fixtures/osv/`. Выполняйте на
|
||||
свежем стенде (раздел 8) или с другим ЮЛ: дедуп и автозамена зависят от уже загруженного. Ожидаемое время от загрузки до
|
||||
`PROCESSED` — до 15 секунд (опрос публикатора и цикла маппинга по 5 с, парсер — доли секунды).
|
||||
|
||||
Подготовка: пользователь `ka_go` (права `DOCUMENT_EDIT`, `DOCUMENT_VIEW`, видит все ЮЛ). ЮЛ должно называться как в шапке ОСВ —
|
||||
`АО "Русский бисквит"`, ИНН `3528052718` (если вы делали импорт I2 — найдите его в `GET /directories/legal-entities?q=бисквит`
|
||||
и используйте существующее, иначе создайте как в D9). Для проверки «чужого филиала» понадобится ГК с филиалом.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
|
||||
-d '{"email":"ka_go@armka.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
|
||||
H="Authorization: Bearer $TOKEN"
|
||||
# ЮЛ (пропустите, если уже есть)
|
||||
LE=$(curl -s -X POST localhost:8000/api/v1/directories/legal-entities -H "$H" -H 'Content-Type: application/json' \
|
||||
-d '{"inn":"3528052718","entity_type":"ЮЛ","name":"АО \"Русский бисквит\""}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
# загрузка (U1)
|
||||
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "$H" -F 'files=@../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls'
|
||||
FILE=<file_id из ответа>
|
||||
# опрос карточки файла (U2): повторяйте до status = PROCESSED
|
||||
curl -s "localhost:8000/api/v1/files/$FILE" -H "$H" | python3 -m json.tool
|
||||
DOC=<documents[0].id из карточки>
|
||||
curl -s "localhost:8000/api/v1/documents/$DOC" -H "$H" | python3 -m json.tool
|
||||
curl -s "localhost:8000/api/v1/documents/$DOC/entries" -H "$H" | python3 -m json.tool
|
||||
curl -s -X POST "localhost:8000/api/v1/documents/$DOC/confirm" -H "$H"
|
||||
curl -s -X DELETE "localhost:8000/api/v1/files/$FILE" -H "$H" -w '%{http_code}\n'
|
||||
```
|
||||
|
||||
| № | Пользователь | Шаги | Ожидание |
|
||||
|---|---|---|---|
|
||||
| U1 | ka_go | загрузить `ОСВ_66 за 2024.xls` для ЮЛ «Русский бисквит» | 201, `accepted` из одного элемента (`file_id`, `job_id`, `name`), `rejected` пуст; сразу после загрузки `GET /files/{id}` — `status` `UPLOADED` или `PROCESSING` |
|
||||
| U2 | ka_go | опрашивать `GET /files/{file_id}` (раз в 2–3 с) | не позднее чем через ~15 с `status = PROCESSED`, `job.status = done`, `job.error_code` пуст; `documents` — один: `document_type = OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `status = RECOGNIZED`, `counterparty_check = MATCH`, `period_check = UNKNOWN` |
|
||||
| U3 | ka_go | `GET /documents/{id}` | `locator = sheet:Лист_1`, `file_id` = файл из U1, `group_schema` с группировками листа, `warnings` пуст, `confirmed_by` пуст |
|
||||
| U4 | ka_go | `GET /documents/{id}/entries` | `total = 9`; первая строка — `account_code = "66"`, `level = 0`, `parent_entry_id` пуст; строка «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» под `66.03` имеет `parent_entry_id` = id строки `66.03`; строки «Итого» нет; суммы — десятичные строки как в Excel (сальдо на конец счёта 66: кредит `54.64`) |
|
||||
| U5 | ka_go | загрузить тот же файл ещё раз | 422, `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — файл из U1; новых файлов нет |
|
||||
| U6 | ka_go | загрузить `.docx` (или `.xlsx`, переименованный из `.xls`) | 422, `rejected[0].code = UNSUPPORTED_FILE` |
|
||||
| U7 | ka_go | загрузить `ОСВ_67 за 2024.xls` и `.docx` одним запросом (`-F 'files=@…' -F 'files=@…'`) | 201, один в `accepted`, один в `rejected` с `UNSUPPORTED_FILE` |
|
||||
| U8 | ka_go | `POST /documents/{id}/confirm` | 200, `status = CONFIRMED`, `confirmed_by`/`confirmed_at` заполнены; повторный вызов — 409 `ALREADY_CONFIRMED` |
|
||||
| U9 | ka_go | `DELETE /files/{file_id}` файла с подтверждённым документом | 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, файл и строки на месте |
|
||||
| U10 | ka_go | для того же ЮЛ загрузить `ОСВ_60 за 2024.xls` (rb) и дождаться `PROCESSED`; затем `../fixtures/osv/schastl/2024-FY/Оборотносальдовая ведомость по счету 60 за 2024 г..xlsx` | второй файл — `PROCESSED`, `version_no = 2` (если оба документа 60 / 2024 / Q4); первый — `SUPERSEDED`, `superseded_by_file_id` = второй; у документа второго файла `counterparty_check = MISMATCH`, `status = NEEDS_CHECK` (шапка — другая организация) |
|
||||
| U11 | ka_go | `GET /legal-entities/{id}/files`, затем с `include_superseded=true` | в первом — только актуальные файлы; во втором — и заменённый `SUPERSEDED` |
|
||||
| U12 | ka_go | `DELETE` второго файла из U10 (заменил первый) | 409 `FILE_SUPERSEDES_OTHERS`; `DELETE` первого (`SUPERSEDED`) — 204, далее `GET /files/{id}` — 404 |
|
||||
| U13 | ka_go | `POST /documents/{id}/confirm` для документа первого файла из U10 (`SUPERSEDED`), до удаления в U12 | 409 `FILE_SUPERSEDED` |
|
||||
| U14 | expert | `POST .../files` | 403 (нет `DOCUMENT_EDIT`); `GET /files/{id}` — 200 (есть `DOCUMENT_VIEW`) |
|
||||
| U15 | client_manager | `GET /files/{id}` файла ЮЛ другого филиала | 404, тело как у случайного uuid |
|
||||
| U16 | ka_go | остановить tabular-parser, загрузить новый файл | `status` остаётся `UPLOADED`/`PROCESSING` ~2 мин (3 повтора по 30 с), затем `ERROR`, `job.error_code` заполнен; `DELETE` такого файла — 204 |
|
||||
|
||||
Аудит (6.4 — запрос из psql): после U1 — `FILE_UPLOAD`; U8 — `DOCUMENT_CONFIRM`; U10 — `FILE_REPLACE` с пустым `user_id`
|
||||
(системное событие) и `description`, где перечислены документы заменённого файла; U12 — `FILE_DELETE` с карточкой файла
|
||||
в `before_value`. Смены статусов обмена (`PROCESSING`, `PROCESSED`, `ERROR`) аудитом не фиксируются — так задумано.
|
||||
|
||||
## 7. Известные ограничения (не заводить как баги)
|
||||
|
||||
- UI только для администрирования; справочники — через API.
|
||||
@ -265,6 +328,9 @@ make migrate && make seed-dev
|
||||
|
||||
Бэкенд и фронт перезапускать не нужно, но сессии в браузере лучше начать заново.
|
||||
|
||||
Файлы, загруженные в 6.6, удаляются из БД командой `DELETE /files/{id}`, но объекты в MinIO остаются (им ставится только
|
||||
`expires_at`) — `down -v` стирает и их.
|
||||
|
||||
## 9. Как заводить баги
|
||||
|
||||
Gitea, репозиторий `RSHB/arm-core` (для документации — `RSHB/arm-ka-docs`). Один дефект — одна issue.
|
||||
@ -296,6 +362,7 @@ Gitea, репозиторий `RSHB/arm-core` (для документации
|
||||
| Фронт открылся, но после входа пусто или ошибки сети | забыты `REACT_APP_API_URL`/`REACT_APP_ROOT_PATH` при `npm run start` |
|
||||
| Страница «Вход выполняется через РАИСА» | это production-сборка; запускайте `npm run start`, а не `build` |
|
||||
| Импорт падает на записи файла в S3 | MinIO не поднят или не создан бакет: `docker compose logs minio-init` |
|
||||
| `/healthcheck` показывает `nats: down` | NATS не поднят; на тестирование Среза 1 не влияет |
|
||||
| `/healthcheck` показывает `nats: down` | NATS не поднят; на сценарии Среза 1 не влияет, но загрузка ОСВ (6.6) без NATS не дойдёт до парсера |
|
||||
| Файл завис в `UPLOADED`/`PROCESSING` | не запущен tabular-parser (`curl localhost:8001/healthcheck`) или ядро запущено без `NATS_ENABLED=true`; без ack файл через ~2 мин станет `ERROR` |
|
||||
|
||||
Подробнее об инфраструктуре — `docs/dev/README.md`, о бэкенде — `arm-core/README.md`.
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
- [x] 1.2 `model/entities-v5.15.md` (копия v5.14 + «Что изменилось в v5.15»), `context/04-data-model-guide.md`, `CLAUDE.md` workspace и
|
||||
`repo-templates/arm-core.CLAUDE.md` → v5.15. Ссылка на DBML в `openspec/specs/quality-checks/spec.md` (там ещё v5.13) —
|
||||
отдельным bounded-коммитом в этой же ветке с пометкой в описании PR (дельты в change со `skip_specs` нет).
|
||||
- [ ] 2.1 После реализации (дочерний change, задачи 2–4): §1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 в `05-domain-rules.md`;
|
||||
- [x] 2.1 После реализации (дочерний change, задачи 2–4): §1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 в `05-domain-rules.md`;
|
||||
`03-architecture.md` — цикл маппинга; `07-open-questions.md` — закрыть OQ-67/69/70/59/109, новые OQ (удаление после консолидаций,
|
||||
ретеншн при удалении); `09-delivery-plan.md` — 2c по факту; `dev/qa-guide.md` — сценарии загрузки и просмотра ОСВ через API.
|
||||
- [ ] 2.2 После merge дочернего change: `openspec archive slice-2c-osv-ingest`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user