diff --git a/context/03-architecture.md b/context/03-architecture.md index f05707f..54c8d63 100644 --- a/context/03-architecture.md +++ b/context/03-architecture.md @@ -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/`, имя файла стандартное `_.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`. -- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.14. +- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.15. ### Инфраструктурные клиенты diff --git a/context/04-data-model-guide.md b/context/04-data-model-guide.md index f9660e8..9559cd6 100644 --- a/context/04-data-model-guide.md +++ b/context/04-data-model-guide.md @@ -1,9 +1,9 @@ -# 04. Как читать модель данных v5.14 +# 04. Как читать модель данных v5.15 -Файлы: `docs/model/data-model-v5.14.dbml` (источник истины), `docs/model/entities-v5.14.md` (смысл таблиц; предыдущие -версии `data-model-v5.13.dbml` / `entities-v5.13.md` остаются как история), +Файлы: `docs/model/data-model-v5.15.dbml` (источник истины), `docs/model/entities-v5.15.md` (смысл таблиц; предыдущие +версии `data-model-v5.13.dbml` / `data-model-v5.14.dbml` и `entities-v5.13.md` / `entities-v5.14.md` остаются как история), `docs/model/diagrams/part01…09.svg` (диаграммы v5.12 — на одну версию старше: нет `permission`/`role_permission`, -нет `document`, есть `osv_group_schema`, нет `related_group_member.kind`; читать с поправкой на changelog v5.13 и v5.14 в шапке DBML). +нет `document`, есть `osv_group_schema`, нет `related_group_member.kind`; читать с поправкой на changelog v5.13, v5.14 и v5.15 в шапке DBML). ## Девять частей модели @@ -54,10 +54,10 @@ ## v4 → v5.13: таблица переименований (обязательна при чтении ТЗ) -v5.14 имена таблиц и полей не менял (добавлены `related_group_member.kind` и два значения `audit_action`), поэтому таблица -справедлива и для v5.14. +v5.14 имена таблиц и полей не менял (добавлены `related_group_member.kind` и два значения `audit_action`), v5.15 — тоже +(добавлено значение `osv_data_type.NU`), поэтому таблица справедлива и для v5.14, и для v5.15. -| В страницах ТЗ (модель v4 и старше) | В v5.13 / v5.14 | +| В страницах ТЗ (модель v4 и старше) | В v5.13 / v5.14 / v5.15 | |---|---| | `stored_file.period_year / period_quarter / document_type / osv_account` | `document.period_year / period_quarter / document_type / osv_account_code / is_cumulative` | | `stored_file.status` «Загружен / Обработка / Требуется проверка / Ошибка» | техника: `stored_file.status` UPLOADED/PROCESSING/PROCESSED/ERROR/SUPERSEDED; проверка: `document.status` RECOGNIZED/NEEDS_CHECK/CONFIRMED/ERROR | diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index e3a8431..4821565 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -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` diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 1ae77fd..502cf75 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -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,15 @@ | OQ-106 | «Итого = корень» сверяется с **первой** корневой строкой ACCOUNT (уровень 0). Лист с несколькими корнями уровня 0 (несколько счетов в одной ОСВ; ОСВ по субконто без строк-счетов) получает `needs_check`, хотя «Итого» может быть равна Σ корней. Сверять «Итого» с суммой корней? | tabular-parser: финальное ревью Среза 2a, M-4 | срез 2a+ (контрольные суммы `checks.py`), чек-лист документов | допущение принято: сверка с первым корнем; многокорневой лист → `needs_check` (аналитик проверяет вручную); в фикстурах таких листов нет | | OQ-107 | При `hierarchy=sums` (дерево восстановлено по равенству «сумма детей = родитель») сверка «сумма детей = родитель» тавтологична — она не может не сойтись. Считать ли такой разбор `needs_check` (или отдельное предупреждение достаточно)? | tabular-parser: финальное ревью Среза 2a, M-5 | срез 2a+ (статус результата), срез 2c (UI проверки) | допущение принято: статус — по контрольным суммам, стратегия видна в `metrics.warnings` (`hierarchy=sums`); на реальных файлах стратегия не выбирается (все — `outline`/`trivial`) | | OQ-108 | Защита фазы чтения xlsx от XML-бомб (entity expansion) — `defusedxml` как новая зависимость сервиса — и иные лимиты памяти чтения. Остаточные риски после R-23/R-25: (1) бомба по объёму самих ячеек в пределах лимита распакованного zip — xlsx в 5 МБ с 2 млн обычных ячеек читается openpyxl 13 с и занимает 1,5 ГБ (замер ревьюера); (2) объём `sharedStrings` и стилей в тех же пределах; (3) entity expansion (от экспоненты «billion laughs» защищает только сам expat ≥ 2.4); (4) **известный обход предсканирования (R-26):** лист в части без `.xml` с ≥ 64 байтами ведущих пробелов и без XML-декларации не попадает в скан (файл 1,8 КБ → 27 с / 1,4 ГБ в openpyxl) — первый follow-up: убрать «sniff» и отдавать expat каждую некаталожную запись; (5) скан сам не ограничен размером чанка: «мусорная» XML-часть с атрибутом в 400 МБ → 1,3 ГБ / 1,2 с, 400 МБ тегов → 8,5 с CPU; (6) xls с очень многими крупными merged-диапазонами (> 10 000 ячеек каждый) — линейный просмотр индекса `Sheet` (≈ 4,4 мс на обращение), останавливает только `PARSE_TIMEOUT_SECONDS`: потолок на число таких диапазонов. Предскан — эшелон защиты, не граница безопасности; настоящая граница — лимит памяти контейнера / подпроцесс с RLIMIT. Рекомендация: жёсткий лимит памяти контейнера сервиса; и/или предпроверка объявленного `` и потоковый счётчик ячеек ``/строк `sharedStrings` (тем же expat-сканом, что объединения) с лимитом; и/или разбор книги в подпроцессе с `RLIMIT_AS`/таймаутом, который можно убить. Решение платформы | tabular-parser: финальное ревью Среза 2a, I-1 (хвост), R-23, R-25 | tabular-parser (readers), платформа (зависимости и лимиты образа/пода) | допущение принято: действуют лимиты R-23/R-25 — распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек книги ≤ 200 000 (потоковый expat-скан всех XML-частей до openpyxl); объём ячеек, `sharedStrings`/стили и entity expansion не ограничены (README «Известные ограничения») | -| OQ-109 | Воркер tabular-parser скачивает объект S3 целиком и только потом проверяет `PARSE_MAX_BYTES`; проверять размер по `stat` до скачивания | tabular-parser: финальное ревью Среза 2a, M-7 | tabular-parser (`core/worker.py`) — вне Среза 2a | открыт: правка воркера — follow-up; лимит размера сейчас срабатывает после скачивания (память под файл выделяется) | +| 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` и правило статуса | +| OQ-117 | Индексы `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)`, `stored_file(current_recognition_job_id)`: `DELETE /files/{id}` (и очистка файла при повторном маппинге) делает seq scan на проверке self-FK — на временной таблице 200 тыс. строк удаление документа в 1000 строк 8,6 с без индекса и 16 мс с индексом (замер в psql на временной таблице, final review); при ~3 млн строк DELETE ≈ 2 мин → HTTP-таймаут и долгое удержание блокировок. Требуется DBML v5.16 + миграция 0004. Решает владелец (источник истины — DBML) | arm-core Срез 2c: final review (I-1, R-24, design №51) | `DELETE /files/{id}`, повторный маппинг; подсрез 2d (UI удаления), прод | **закрыт в v5.15 (06.10.2026):** индексы добавлены в DBML v5.15 (п. 4 changelog) и миграцию `0003_dbml_v5_15` arm-core — `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`, `ix_stored_file_current_recognition_job_id`; v5.16 и миграция 0004 не понадобились (v5.15 ещё не слит) | Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; `DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 4209962..2120f00 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -114,14 +114,40 @@ 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` (контроллер, HEAD f9b8a21): EXIT 0 — unit 900, integration 437, e2e skipped (профиль + `services` не поднят), docker-build и web-check OK; отдельный прогон e2e с реальным `tabular-parser`: 1 passed (4,3 с); + `-m fixtures`: 13 passed + 1 skipped. +- **Решения без обсуждения** — `arm-core/openspec/changes/slice-2c-osv-ingest/design.md`, разделы «Decisions made without + discussion» (№1–52). Правила — `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 «Первичные документы» diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 165542f..29226c9 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -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: . Сервер слушает только `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= +# опрос карточки файла (U2): повторяйте до status = PROCESSED +curl -s "localhost:8000/api/v1/files/$FILE" -H "$H" | python3 -m json.tool +DOC= +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` (ack нет — в `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`. diff --git a/model/data-model-v5.15.dbml b/model/data-model-v5.15.dbml new file mode 100644 index 0000000..5c5551a --- /dev/null +++ b/model/data-model-v5.15.dbml @@ -0,0 +1,1329 @@ +// ДКБ · Модель данных v5.15: справочники, файлы/распознавание, консолидация, КФО, аудит, ЕХФД +// Формат: DBML (dbdiagram.io). Вставить на https://dbdiagram.io/d +// >>> Источник истины по модели данных. Аналитика в Confluence ссылается сюда. +// Над каждой таблицей — блок «ИСТОЧНИК»: пункт ТЗ АРМ КА BIQ-34 / ТЗ ЕХФД (помечено «ЕХ ТЗ») / Методика; +// страница аналитики (md); экран макета dkb_24.09.26; версия модели, в которой таблица появилась (v4 = исходная DBML аналитики). +// +// v5.15 (Срез 2c, решения владельца 05.10.2026): +// 1. osv_data_type: добавлено NU — строки налогового учёта из ОСВ «БУ, НУ» хранятся в osv_entry как есть (OQ-45); +// контракт результата допускал NU с v1.0. +// 2. app_user.email хранится в нижнем регистре: нормализация при записи (seed, администрирование), поиск по +// lower(email) остаётся; уникальность — существующий UNIQUE по email (OQ-59). Структура не меняется. +// 3. Без изменений структуры, уточнение семантики: stored_file.status — PROCESSING ставится inbox'ом по ack сервиса, +// PROCESSED/ERROR — циклом маппинга результата (OQ-67); маппинг выполняется отдельно от inbox (OQ-69); +// recognition_job.group_schema заполняет маппер; «Итого» в osv_entry не хранится (OQ-01). +// 4. Индексы для удаления файла и обхода дерева строк: osv_entry (parent_entry_id), osv_entry (recognition_job_id), +// stored_file (current_recognition_job_id) — проверки FK при DELETE документа/файла и очистке перед повторным +// маппингом шли seq scan'ом (OQ-117). Замер: DELETE документа 1000 строк 8,6 с → 16 мс на 200 тыс. строк. +// +// v5.14 (Срез 1, решения владельца 04.10.2026): +// 1. ЮЛ может состоять ОДНОВРЕМЕННО в одной ГСК и одной ГСЗ (но только в одной группе каждого вида) — отменяет +// ANL-030/v5.8. В related_group_member возвращена денормализованная колонка kind (= related_group.kind, +// равенство проверяет ядро при записи); частичный уникальный индекс стал (legal_entity_id, kind) WHERE is_current. +// 2. КПП у ЮЛ не вводится (OQ-31: поле есть только в макете; решение владельца — не нужен). +// 3. Без изменений структуры, уточнение семантики: company_group.crm_id = «iD ГК» выгрузки ЕЦП CRM +// (docs/source/biq/ecp-export-lagal-entity.xlsx), base_regional_office — «Базовый РФ ГК в CRM»; флаги «входит в ГСК/ГСЗ» +// выгрузки → членство в одной неявной ГСК/ГСЗ на ГК (правила импорта — 05-domain-rules.md §13). +// 4. audit_action: добавлены LEGAL_ENTITY_CHANGE (создание/изменение/закрытие/перевод ЮЛ, алиасы) и USER_CHANGE +// (создание/деактивация пользователя администратором) — ТЗ 8.2 «журнал изменений справочников» их требует, +// в v5.13 были только GROUP_CHANGE / MEMBERSHIP_CHANGE / USER_ROLE_CHANGE. +// +// v5.13 (ревью 30.09–01.10, замечания 1–7; п.2 parent_entry_id — отложен): +// 1. Права как данные: permission + role_permission (матрица доступов ТЗ сноска 29 — в БД, не в коде); +// права только через роли, прямых назначений пользователю нет. Отменяет решение v5.4. +// 3. osv_group_schema убрана — схема группировок шапки = recognition_job.group_schema jsonb; +// тип строки — osv_entry.group_type. +// 4. financial_statement_value.needs_review убран — производное от confidence/is_recognized, считается запросом. +// 5. recognition_job: оставлен только result_s3_object_id; сырой ответ ИС Сканирование в БД не регистрируется +// (scan-gateway пишет его в S3 по конвенции raw/{job_id}.json для разбора инцидентов). +// 6. stored_file.fields_modified убран — COUNT(is_modified) по данным документа. +// 6-бис. Разделение «файл / документ»: stored_file = физический файл (один на загрузку), document = логическая +// единица (ББ, ОФР, ОСВ по счёту, расшифровка) — один файл содержит N документов (PDF с ББ+ОФР, xlsx с +// несколькими ОСВ листами). Чек-лист, проверки периода/контрагента, подтверждение, FK разобранных данных, +// consolidation_document и ex_snapshot_item — на document. Замена (superseded_by) остаётся на файле целиком. +// 7. file_type: убран P7. +// +// Изменения относительно v4 (кратко): +// FIX — исправлены дефекты v4 (PK у meta, типы, копипаст-note про «контрагента», row_type, parent_entry_id) +// ADD — audit_event, status_history, company_group_assignment, elimination_rule, statement_check_result, +// consolidated_osv_line, chess_cell, vgo_pair_match (агрегат по ТЗ 6.4), ofr_vgo_match, kfo_result_*, +// client_kfo_line, comment, export_log/preset, ex_snapshot*, correction_version +// CHG — role → 7 ролей ТЗ + сегмент (ЗО); consolidation_version — сценарий/подход/гудвилл/решение УОБ; +// stored_file — счёт ОСВ, версии/замена; osv_entry — признак контрагента + override; legal_entity — монозаёмщик +// +// Ключи: у ВСЕХ сущностей суррогатный PK (uuid). Натуральные ключи — UNIQUE-колонки. +// (Для высокообъёмных таблиц — osv_entry, audit_event — uuid можно заменить на bigint identity.) +// ЮЛ и ГК — SCD Type 4 (живая таблица + *_history). Членство в ГСК/ГСЗ — SCD Type 2 (is_current). +// Первичка не мутируется: правки распознанных значений = поле + *_original_value + is_modified + audit_event. +// Производные объекты версии (сводная ОСВ, шахматка, КФО) — snapshot на consolidation_version: +// пересчитываются пока версия «в работе», замораживаются при VERIFIED (ТЗ 6.10, 7.2). +// +// DECISION-1 (ТЗ 6.4 vs v4): первичная модель сопоставления ВГО — АГРЕГАТ по паре компаний × правилу пары счетов +// (vgo_pair_match), как в ТЗ/Прил. 3. Строковое 1:1 сопоставление (vgo_match) сохранено как детализация агрегата. +// DECISION-2 (ЕХФД): ЕХ — те же таблицы + ex_snapshot как «замороженный указатель» на состав верифицированной версии. +// Отдельная физическая БД для ЕХ не предполагается; архив в data lake — выгрузка снапшота. +// DECISION-3: ГО — обычная запись regional_office с is_head_office=true (вместо NULL). +// DECISION-5 (производные консолидации): сводная ОСВ, пары ВГО и шахматка НЕ хранятся, пока версия в работе — +// считаются запросом из osv_entry (актуальные прогоны) × consolidation_member × elimination_rule. +// Признак контрагента ГСК/ГК/Внешний — функция от osv_entry.counterparty_id и периметра версии; ручное действие +// аналитика = сопоставление контрагента с ЮЛ (counterparty_id + алиас), комментарий — в audit_event. +// При VERIFIED производные замораживаются один раз: структурированный снапшот — jsonb в ex_snapshot_item.payload +// (просмотр в ЛК ЕХ), подготовленный xlsx для выгрузки — s3_object. Отдельная NoSQL-база не вводится. +// Корректировки и гудвилл ссылаются на пару ВГО по естественному ключу (правило, компания 1, компания 2). +// DECISION-4 (распознавание): два отдельных stateless-сервиса без доступа к БД — +// tabular-parser (XLS/XLSX/XLSB) и scan-gateway (адаптер ИС Сканирование для PDF/PNG/JPG). +// Запрос: NATS recognition.{tabular|image}.request + файл в S3 uploads/. Результат: JSON в S3 results/ + +// NATS recognition.result. Единственный писатель в БД — arm-core; корреляция по recognition_job.id; +// идемпотентность по msg_id; outbox при создании recognition_job; DLQ. +// v5.13: результат одного файла содержит N документов; ядро создаёт document при маппинге результата. +// +// v5.1 (по макету dkb_24.09.26): consolidation_lock (взятие в работу), participant_disclosure (расшифровки участника), +// legal_entity_history.event_type, statement_meta реквизиты (ОКПО/ОКОПФ/ОКФС/адрес), kfo_result_line: vgo/share, +// client_kfo_line: ВГО клиента/банка + причина, audit_event: section/result/user_agent, статусы REJECTED/ARCHIVED, +// document_check (проверка периода/контрагента файла), company_group.crm_id nullable (ANL-037). +// v5.2 (DECISION-4): recognition_job: subject/msg_id/deadline/external_task_id/result_schema_version/error_code, статусы timeout/dlq. +// v5.12 (ревью part09): ЕХ самодостаточен — срез не ссылается FK на консолидацию и её результаты (их удаляют по сроку, +// срез хранится «вечно»): ex_snapshot.source_version_id/no без FK; ex_snapshot_item = stored_file_id (исходники храним) +// ИЛИ payload jsonb (сводная ОСВ, ВГО, шахматка, КФО, гудвилл, корректировки); убраны object_type/object_id, +// prepared_s3_object_id (готовые файлы не храним), ex_snapshot.source_system, audit_event.s3_object_id; +// добавлен ex_snapshot.superseded_by_snapshot_id (архивный → актуальный). +// v5.11: consolidation_version.elimination_rules_version — какая версия правил Прил. 3/4 применялась (объяснимость и +// воспроизводимость расчёта замороженной версии). +// v5.10 (DECISION-5, ревью part08): убраны consolidated_osv_line, consolidated_osv_source, vgo_pair_match, chess_sheet, chess_cell, +// osv_entry.counterparty_type (выводится); correction ссылается на пару по (rule_id, company_1_id, company_2_id); +// ex_snapshot_item получил payload jsonb. +// v5.9 (ревью part06/07): statement_check_result убрана — проверки считаются на лету, акцепт КА — на документе +// (financial_statement_meta.checks_accepted_*); reviewed_by/at перенесены в meta (документ ревьюится целиком); +// financial_statement_line: убраны ordinal/row_kind/section (порядок и группирующие строки — шаблон формы на фронте); +// consolidation: company_group_id NOT NULL, related_group_id nullable — ГСК фиксируется при VERIFIED (ANL-029); +// consolidation_version: убран package_uuid (документы в ИС Сканирование по одному, ключ = recognition_job.id), добавлен updated_by. +// v5.8 (ревью part01): убраны company_group_assignment (видимость ТЗ 8.1 выводится из company_group.base_regional_office_id, +// legal_entity.regional_office_id, app_user.regional_office_id; история перезакрепления — company_group_history), +// legal_entity.is_mono_borrower (монозаёмщик = company_group_id IS NULL), related_group_member.kind и индекс related_group(id, kind) +// (после ANL-030 partial-unique по legal_entity_id без условия на kind). +// v5.7: метрики качества распознавания — recognition_job.fields_total/low_confidence/unrecognized (что пришло от сервиса). +// (stored_file.fields_modified/fields_reviewed и financial_statement_value.needs_review — убраны в v5.9/v5.13 как производные.) +// v5.6 (ревизия 28.09, решения по каждой убранной таблице): +// account_code_alias — нормализация кодов в коде; редкие субсчета клиентов добавляются в chart_of_account +// vat_rate — константы; средневзвешенная ставка хранится в vgo_pair_match +// crm_import_batch — импорт справочников только на старте (миграция); след в audit_event(DIRECTORY_IMPORT) + файл в s3_object +// osv_entry_edit — audit_event(RECOGNIZED_DATA_EDIT) + osv_entry.original_values/modified_by/modified_at +// document_check — колонки period_check/counterparty_check (с v5.13 — на document); сверка с периодом версии вычисляется +// export_preset — выбор колонок в момент экспорта, без сохранения наборов +// export_log — audit_event(EXPORT, after_value={report_code, format, columns, rows}); файлы выгрузок не хранятся (S3 lifecycle exports/ = 7 дней) +// v5.5: убрана correction_version — история корректировок (сноска 22) ведётся audit_event(before/after), version_no в correction. +// v5.4: возвращён s3_object (реестр объектов S3 — lifecycle/ретеншн ТЗ 8.4, sha256, единая точка ключей); permission/role_permission — матрица в коде (ОТМЕНЕНО в v5.13). +// v5.3 РЕВИЗИЯ по правилу «в базе только то, что есть в ТЗ или на экране макета». Удалено 14 таблиц: +// - account_code_alias: нормализация кодов (60.1=60.01) — алгоритм в коде (ARCH-004), не справочник +// - vat_rate: ставки 0/10/20/22 — константы; средневзвешенная ставка хранится в vgo_pair_match.vat_weighted_rate +// - disclosure_item: расшифровки участника: в UI скрыты, в ТЗ только тип документа «Расшифровка» → вне scope до решения ANL-040 +// - participant_disclosure: см. disclosure_item +// - crm_import_batch: журнал импортов не в ТЗ/UI; факт импорта и ошибки — audit_event(GROUP_CHANGE/IMPORT) +// - permission / role_permission: ВОЗВРАЩЕНЫ в v5.13 (замечание 1) +// - integration_message: РЕШЕНИЕ — обмен с NATS завязан только на recognition_job (status queued→published = outbox; result_msg_id UNIQUE = inbox/дедуп); отдельная таблица появится, только если возникнет второй тип сообщений +// - osv_entry_edit: история правок сумм ОСВ — audit_event(RECOGNIZED_DATA_EDIT, before/after) +// - document_check: результат проверки периода/контрагента — две колонки (с v5.13 — в document) +// - vgo_match: строковое сопоставление 1:1 — только в аналитике, нет ни в ТЗ (агрегат по Прил. 3), ни в UI; детализация агрегата — consolidated_osv_source +// - export_preset: сохранённые пресеты колонок не в ТЗ/UI (выбор столбцов — при экспорте, без сохранения) +// - export_log: история выгрузок (ТЗ 8.2, ЕХ 6.6) — audit_event(EXPORT) с составом в after_value + +// ============ ENUMS ============ +Enum entity_type { + "ЮЛ" + "ИП" +} + +Enum related_group_kind { + gsk + gsz +} + +Enum account_kind { + A [note: 'актив'] + P [note: 'пассив'] + AP [note: 'активно-пассивный'] +} + +Enum account_row_type { + account [note: 'синтетический счёт, напр. 60'] + subaccount [note: 'субсчёт, напр. 60.01'] + analytic [note: 'подсубсчёт, напр. 60.01.1 / 76.07.1'] +} + +Enum business_segment { + DKB [note: 'крупный бизнес'] + DMSB [note: 'малый и средний'] + DM [note: 'микробизнес'] +} + +Enum document_type { + BALANCE [note: 'Бухгалтерский баланс (0710001)'] + INCOME [note: 'ОФР (0710002)'] + OSV [note: 'ОСВ по счёту — osv_account_code обязателен'] + DISCLOSURE [note: 'Расшифровка'] + OTHER [note: 'Прочий документ'] +} + +Enum file_type { + XLS + XLSX + XLSB + PDF + PNG + JPG +} + +Enum recognition_pipeline { + tabular [note: 'XLS/XLSX/XLSB — tabular-parser'] + image [note: 'PDF/PNG/JPG — ИС Сканирование через scan-gateway'] +} + +Enum period_quarter { + Q1 + Q2 + Q3 + Q4 +} + +// v5.13: статус ФАЙЛА — только техническая обработка. Проверка и подтверждение аналитиком — на document. +Enum file_status { + UPLOADED [note: 'Загружен: поступил, в очереди'] + PROCESSING [note: 'Обработка: распознавание/парсинг'] + PROCESSED [note: 'Результат получен, документы созданы'] + ERROR [note: 'Ошибка: нечитаемый файл, требуется повторная загрузка'] + SUPERSEDED [note: 'Заменён новой версией файла (ТЗ 8.2) — вместе со всеми документами в нём'] +} + +// v5.13: статус ДОКУМЕНТА (ANL-003: «Требуется проверка» при confidence < 100% или непройденных проверках; +// «Подтвержден» — финальный после проверки аналитиком) +Enum document_status { + RECOGNIZED [note: 'данные получены, ручная проверка не требуется по порогу'] + NEEDS_CHECK [note: 'Требуется ручная проверка: confidence < 100% / контрольные суммы / период или контрагент не совпали'] + CONFIRMED [note: 'Подтвержден аналитиком: данные зафиксированы, доступны консолидации'] + ERROR [note: 'документ не разобран (напр. не найдена шапка ОСВ)'] +} + +Enum s3_bucket_role { + uploads + results + exports + archive +} + +Enum recognition_status { + queued [note: 'создано, запрос в outbox'] + published [note: 'запрос опубликован в NATS'] + processing [note: 'сервис подтвердил взятие в работу (опц. heartbeat)'] + done + needs_check [note: 'результат получен, но confidence < 100% / контрольные суммы не сошлись'] + error + timeout [note: 'reply_deadline истёк без результата'] + dlq [note: 'сообщение ушло в DLQ после max_deliver'] +} + +Enum statement_type { + balance + income +} + +Enum osv_group_type { + ACCOUNT + DEPARTMENT + COUNTERPARTY + CONTRACT + OTHER +} + +Enum osv_data_type { + BU + NU [note: 'v5.15: налоговый учёт из ОСВ «БУ, НУ» (OQ-45)'] + CURRENCY + QUANTITY + SUM +} + +Enum consolidation_scenario { + CREATE [note: '«Создание КФО» при отсутствии клиентской консолидации'] + CONFIRM [note: '«Подтверждение КФО клиента»'] +} + +Enum consolidation_approach { + BASIC [note: 'базовый: ББ, ОФР, ОСВ 01,58,60,62,66,67,76,90'] + EXTENDED [note: 'опция к базовому: + ОСВ 02,03,08,44,26,91,51,52,59,80,83,86,97,001,008,009,011'] +} + +// Статусы КФО по ТЗ 6.10 + «Корректировка» при расхождении ≥5% (ТЗ 6.4) +Enum consolidation_status { + INITIATED + DOCUMENTS_PROCESSING + DATA_CORRECTION [note: 'корректировка распознанных данных'] + CONSOLIDATION_RF + ADJUSTMENT [note: '«Корректировка» ВГО (расхождение ≥ 5%); подстатус этапа консолидации'] + VERIFICATION_RF + CONSOLIDATION_GO + VERIFICATION_GO [note: 'РКА ГО, если КФО инициировано КА ГО (ТЗ 6.10 маршрут)'] + VERIFIED + CANCELLED + // v5.1 по макету — ANL-035: подтвердить с заказчиком + REJECTED [note: 'Отклонено: возвращено на доработку (макет). Альтернатива — событие в status_history без отдельного статуса'] + ARCHIVED [note: 'Архив: скрыто из основного списка (макет); применимо к VERIFIED/CANCELLED'] +} + +Enum correction_status { + DRAFT + APPLIED + REVOKED +} + +Enum document_check_result { + MATCH + MISMATCH + UNKNOWN +} + +Enum le_history_event { + CREATE + UPDATE + MOVE_TO_GROUP [note: '«Переход в другую ГК»'] + CLOSE [note: '«Закрытие» / вывод из состава ГК'] + REMOVE_FROM_GROUP +} + +Enum ex_snapshot_status { + ACTUAL + ARCHIVE +} + +// v5.13: объекты и действия матрицы прав (ТЗ Прил. 2 «Роль: перечень полномочий», сноска 29) +Enum permission_object { + DIRECTORY [note: 'справочники ГК/ГСК/ГСЗ/ЮЛ'] + DOCUMENT [note: 'первичные документы и распознанные данные'] + CONSOLIDATION [note: 'консолидация, версии, корректировки, гудвилл'] + KFO [note: 'результат КФО: утверждение, верификация'] + EX [note: 'Единое хранилище'] + EXPORT + USERS [note: 'пользователи и роли'] + AUDIT [note: 'журнал действий'] +} + +Enum permission_action { + VIEW + EDIT + APPROVE [note: 'утверждение в РФ'] + VERIFY [note: 'верификация в ГО'] + DELETE + EXPORT + ADMIN +} + +Enum audit_section { + DIRECTORIES + CONSOLIDATION + PRIMARY_DOCUMENTS + RECOGNITION + KFO + EXPORT + ACCESS + SESSION + USERS +} + +Enum audit_action { + LOGIN + LOGIN_FAILED + LOGOUT + CONSOLIDATION_CREATE + CONSOLIDATION_STATUS_CHANGE + CONSOLIDATION_APPROVE + CONSOLIDATION_VERIFY + GROUP_CHANGE + LEGAL_ENTITY_CHANGE [note: 'v5.14: ЮЛ создано/изменено/закрыто/переведено, алиасы'] + MEMBERSHIP_CHANGE + ASSIGNMENT_CHANGE + FILE_UPLOAD + FILE_DELETE + FILE_REPLACE + DOCUMENT_CONFIRM + CONSOLIDATION_LOCK + CONSOLIDATION_LOCK_TAKEOVER + DISCLOSURE_EDIT + RECOGNIZED_DATA_EDIT + OSV_COUNTERPARTY_MAP + OSV_TYPE_OVERRIDE + CORRECTION_CREATE + CORRECTION_EDIT + CORRECTION_REVOKE + GOODWILL_EDIT + EXPORT + DIRECTORY_IMPORT + USER_ROLE_CHANGE + USER_CHANGE [note: 'v5.14: пользователь создан/изменён/деактивирован администратором'] + ROLE_PERMISSION_CHANGE +} + +// ============ СПРАВОЧНИКИ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (пользователь ГО / пользователь РФ; закрепление ГК за РФ); Прил. 2 «Пользователь: привязка к РФ/ГО» +// Аналитика: md «Модель данных» (regional_office) +// Макет: справочник ГК: колонка «Основной РФ»; пользователи: «Ростовский РФ» и т.п. +// В модели: v4 +// Зачем: справочник филиалов; DECISION-3 — ГО явной записью +Table regional_office { + id uuid [pk] + name varchar [not null, unique] + is_head_office boolean [not null, default: false, note: 'DECISION-3: ГО — явная запись'] + is_active boolean [not null, default: true] + Note: 'Филиал: ГО и региональные филиалы (РФ)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (справочник ГК, импорт из ЕЦП CRM, создание вручную Куратором); Прил. 2 «ГК: id, наименование, id ЕЦП CRM» +// Аналитика: md «Модель данных» (company_group, SCD4) +// Макет: «Группы компаний»: Создать ГК, CRM ID «генерируется автоматически», Архив +// В модели: v4 + v5.1 (code, crm_id nullable — ANL-037) +Table company_group { + id uuid [pk] + code varchar [not null, unique, note: 'v5.1 ANL-037: внутренний код ГК, генерируется системой'] + crm_id varchar [unique, note: 'ключ из ЕЦП CRM; NULL для ГК, созданной вручную Куратором до сверки с CRM'] + name varchar [not null] + base_regional_office_id uuid [ref: > regional_office.id, note: 'закрепление ГК за филиалом (ТЗ 6.1/8.1); перезакрепление Куратором = UPDATE + снимок в history'] + is_archived boolean [not null, default: false, note: 'v5.1 макет: «Архив» ГК'] + valid_from timestamptz [not null] + updated_by uuid [ref: > app_user.id] + created_at timestamptz + updated_at timestamptz + Note: 'ГК — ТЕКУЩЕЕ состояние (SCD4). История — company_group_history' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 «фиксация изменений ГК/ГСК в истории изменений»; 8.2 «изменение состава ГСК, ГК» +// Аналитика: md «Модель данных» (company_group_history) +// Макет: «История изменений» в карточке ГК +// В модели: v4 +// Зачем: прошлые снимки ГК (SCD4) +Table company_group_history { + id uuid [pk] + company_group_id uuid [not null, ref: > company_group.id] + crm_id varchar + name varchar + base_regional_office_id uuid + valid_from timestamptz + valid_to timestamptz + changed_by uuid [ref: > app_user.id] + Indexes { (company_group_id, valid_from) [unique] } +} + + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (ЮЛ/ИП, только одна ГСК/ГСЗ); Прил. 2 «ЮЛ: id, наименование, ИНН, id CRM, ГК, признак»; ЕХ ТЗ 6.1 «Монозаёмщик» +// Аналитика: md «Модель данных» (legal_entity, SCD4) +// Макет: «Юридические лица», «ЮЛ вне ГК» / «Монозаемщики» +// В модели: v4 + v5 (crm_id, is_mono_borrower, ГК nullable) +Table legal_entity { + id uuid [pk] + inn varchar [not null, unique] + crm_id varchar [unique, note: 'ADD: id ЮЛ в ЕЦП CRM (Прил. 2 ТЗ)'] + entity_type entity_type + name varchar [not null] + company_group_id uuid [ref: > company_group.id, note: 'nullable: NULL = монозаёмщик (ЕХ 6.1), признак выводится'] + regional_office_id uuid [ref: > regional_office.id] + close_date date [note: 'NULL = активна'] + valid_from timestamptz [not null] + updated_by uuid [ref: > app_user.id] + created_at timestamptz + updated_at timestamptz + Note: 'Организация (ЮЛ/ИП) — ТЕКУЩЕЕ состояние (SCD4). История — legal_entity_history' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.2 (журнал изменений справочников); 6.10 (история изменений состава) +// Аналитика: md «Модель данных» (legal_entity_history) +// Макет: Архив ГК: события «Переход в другую ГК», «Закрытие», дата перевода +// В модели: v4 + v5.1 (event_type, effective_date — ANL-046) +Table legal_entity_history { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id] + inn varchar + crm_id varchar + entity_type entity_type + name varchar + company_group_id uuid + regional_office_id uuid + close_date date + event_type le_history_event [note: 'v5.1 макет: «Переход в другую ГК» / «Закрытие» — для архива ГК'] + effective_date date [note: 'дата перевода/закрытия (не раньше текущей — ANL-046)'] + valid_from timestamptz + valid_to timestamptz + changed_by uuid [ref: > app_user.id] + Indexes { (legal_entity_id, valid_from) [unique] } +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.5 «ведение справочника соответствий наименований контрагентов», «использование ранее подтверждённых сопоставлений» +// Аналитика: md «Сводная ОСВ» (алгоритм ручного сопоставления, legal_entity_alias); FNR01 п.9 +// Макет: карточка ЮЛ: «Альтернативные наименования», «Связь с компанией из ОСВ (алиас)» +// В модели: v4 +Table legal_entity_alias { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id] + alias varchar [not null, note: 'как в ОСВ'] + normalized_alias varchar [not null, note: 'lower/trim/без пунктуации/без ОПФ'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + Indexes { + (legal_entity_id, normalized_alias) [unique] + normalized_alias [note: 'ANL-028: конфликт «один алиас у разных ЮЛ» решается при ручном сопоставлении'] + } + Note: 'Словарь соответствий наименований контрагентов (ТЗ 6.5)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (ГСК/ГСЗ в составе ГК; иерархия не поддерживается; в одной ГК несколько ГСК — сноска 11); Прил. 2 «ГСК/ГСЗ» +// Аналитика: md «Модель данных» (related_group, kind) +// Макет: вкладки «ГСК», «ГСЗ», коды GSK-001/GSZ-001 +// В модели: v4 +Table related_group { + id uuid [pk] + code varchar [not null, unique] + company_group_id uuid [not null, ref: > company_group.id] + kind related_group_kind [not null] + name varchar [not null] + created_date date + Note: 'ГСК/ГСЗ — current-state. Иерархия групп не поддерживается (ТЗ 6.1)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 «КА может редактировать состав ГСК»; «одно ЮЛ только в одной ГСК или ГСЗ» +// Аналитика: md «Модель данных» (related_group_member, SCD2) +// Макет: «Состав», «Входит в ГСК / ГСЗ», «Редактировать состав» +// В модели: v4 (индекс уточнён — ANL-030); v5.14 — kind и индекс (legal_entity_id, kind) +Table related_group_member { + id uuid [pk] + related_group_id uuid [not null, ref: > related_group.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + kind related_group_kind [not null, note: 'v5.14: денормализация related_group.kind ради частичного уникального индекса; равенство с группой проверяет ядро при записи'] + valid_from timestamptz [not null] + valid_to timestamptz + is_current boolean [not null] + created_by uuid [ref: > app_user.id] + Indexes { + (related_group_id, legal_entity_id) [note: 'partial UNIQUE WHERE is_current'] + (legal_entity_id, kind) [note: 'partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно (решение владельца 04.10.2026, отменяет ANL-030); ГК у ЮЛ и у группы должна совпадать (проверяет ядро)'] + } + Note: 'Состав ГСК/ГСЗ — SCD2. Исключение = закрытие версии (valid_to, is_current=false)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2/6.5 (счета и субсчета 01…90, нормализация 60.1=60.01); Прил. 3/4 (пары счетов); сноска 32 +// Аналитика: md «Модель данных» (chart_of_account); md «Шахматка» (kind A/P/AP, row_type) +// Макет: фильтр «Дебетовые/Кредитовые счета», полный план счетов 1С в бандле +// В модели: v4 + v5 (row_type, is_off_balance) +Table chart_of_account { + id uuid [pk] + code varchar [not null, unique, note: 'нормализованный код: 60.01, 76.07.1'] + parent_id uuid [ref: > chart_of_account.id] + name varchar [not null] + kind account_kind + row_type account_row_type [not null, note: 'FIX: используется в «Шахматке»'] + is_off_balance boolean [not null, default: false, note: '001, 008, 009, 011'] + Note: 'План счетов' +} + + +// ADD ТЗ Прил. 3/4 как данные (сноски 32/35: пары могут корректироваться на этапе реализации) +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 + Прил. 3 «Перечень статей ББ» и Прил. 4 «Модель расчётов ОФР»; сноски 32/35 «могут быть скорректированы на этапе реализации» +// Аналитика: md «Автоматическое сопоставление ВГО» (таблица «Правила встречных счетов» — хардкод) +// Макет: — +// В модели: v5 новая +// Зачем: правила элиминации как данные, версионируемые +Table elimination_rule { + id uuid [pk] + statement_type statement_type [not null, note: 'balance = Прил. 3, income = Прил. 4'] + rule_no smallint [not null, note: '№ строки приложения'] + article_1_line varchar [not null, note: 'статья Компании 1, код строки ББ/ОФР, напр. 1230'] + article_2_line varchar [not null, note: 'статья Компании 2, напр. 1520'] + side_1 varchar [not null, note: 'D | K — сторона Компании 1'] + measure varchar [not null, note: 'closing | turnover — сальдо на конец (ББ) или оборот (ОФР)'] + elimination_mode varchar [not null, note: 'min | full | manual — min(A,B) / вся сумма ВГО (ОФР выручка) / вручную'] + vat_handling varchar [note: 'none | check_90_01_vs_2110 | weighted_rate — только для ОФР'] + excise_adjust boolean [not null, default: false] + comment text + version_no int [not null] + valid_from date [not null] + valid_to date + Indexes { (statement_type, rule_no, version_no) [unique] } + Note: 'Правило элиминации. Состав счетов — в elimination_rule_account' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 3/4 (столбцы «Пары счетов Компании 1 и Компании 2», исключения «76 кроме 76.7.1») +// Аналитика: md «Автоматическое сопоставление ВГО» +// Макет: — +// В модели: v5 новая +// Зачем: состав счетов сторон правила +Table elimination_rule_account { + id uuid [pk] + rule_id uuid [not null, ref: > elimination_rule.id] + company_no smallint [not null, note: '1 или 2'] + account_id uuid [not null, ref: > chart_of_account.id] + is_excluded boolean [not null, default: false, note: 'true = исключение внутри маски (напр. «76 кроме 76.7.1»)'] + Note: 'Счета/субсчета сторон правила' +} + + +// ============ ПОЛЬЗОВАТЕЛИ И ДОСТУП ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (роли, ГО/РФ, ЗО); Прил. 2 «Пользователь: ФИО, роль, привязка к РФ/ГО, ЗО, табельный номер»; РАИСА +// Аналитика: md «Аутентификация и авторизация» (users: email, role, JWT РАИСА); md «Модель данных» (app_user) +// Макет: «Пользователи»: Логин, Роль, Активен, Деактивация +// В модели: v4 + v5 (employee_no, business_segment) +Table app_user { + id uuid [pk] + external_id varchar [unique, note: 'субъект РАИСА/SSO; аутентификация внешняя (ANL-018)'] + email varchar [unique, note: 'v5.15: хранится в нижнем регистре (нормализация при записи), сравнение по lower(email) (OQ-59)'] + full_name varchar [not null] + employee_no varchar [note: 'ADD: табельный номер (Прил. 2 ТЗ)'] + regional_office_id uuid [not null, ref: > regional_office.id, note: 'ГО или РФ (DECISION-3)'] + business_segment business_segment [note: 'ADD: зона ответственности ДКБ/ДМСБ/ДМ (ТЗ 8.1)'] + is_active boolean [not null, default: true] + created_at timestamptz + updated_at timestamptz +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 таблица ролей (КА РФ, КА ГО, РКА, КМ, Куратор, Эксперт, Администратор); Прил. 2 «Роль» +// Аналитика: md «Ролевая модель»; md «Модель данных» (role.code) +// Макет: «Изменение ролей и прав доступа» (Аналитик ДКБ, Наблюдатель… — расходится с ТЗ, ANL-034) +// В модели: v4 (коды → ТЗ в v5); v5.13 — матрица полномочий в role_permission +Table role { + id uuid [pk] + code varchar [not null, unique, note: 'ТЗ 8.1: KA_RF | KA_GO | RKA | CLIENT_MANAGER | CURATOR_GO | EXPERT | ADMIN. Seed; администратор может заводить дополнительные роли (замена исключениям по пользователю)'] + name varchar [not null] + description text + is_system boolean [not null, default: false, note: 'роли ТЗ 8.1 — не удаляются'] + is_active boolean [not null, default: true] + Note: 'Роль. Полномочия — через role_permission (v5.13); код роли в бизнес-логике больше не проверяется' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Роль: перечень полномочий»; сноска 29 «матрица доступов будет разработана» +// Аналитика: md «Ролевая модель» +// Макет: карточка роли: «Права: Просмотр, редактирование КФО / справочники / консолидации» +// В модели: v5 новая, убрана в v5.3/v5.4, ВОЗВРАЩЕНА в v5.13 (замечание 1) +// Зачем: атомарное право «объект × действие»; seed, администратор новые коды не создаёт +Table permission { + id uuid [pk] + code varchar [not null, unique, note: 'напр. CONSOLIDATION_EDIT, KFO_VERIFY, DIRECTORY_EDIT, USERS_ADMIN'] + object_type permission_object [not null] + action permission_action [not null] + name varchar [not null] + description text + Indexes { (object_type, action) [unique] } + Note: 'Справочник прав (seed, привязан к коду). Ограничения по ЗО/филиалу и статусу КФО — отдельный policy-слой поверх прав: право говорит «может», policy — «эту ли и сейчас ли»' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Роль: перечень полномочий» +// Аналитика: нет +// Макет: «Права» в карточке роли, «Изменение ролей и прав доступа» +// В модели: v5.13 (возвращена) +// Зачем: матрица доступов как данные; редактируется администратором, история — audit_event(ROLE_PERMISSION_CHANGE) +Table role_permission { + role_id uuid [not null, ref: > role.id] + permission_id uuid [not null, ref: > permission.id] + granted_by uuid [ref: > app_user.id] + granted_at timestamptz + Indexes { (role_id, permission_id) [pk] } + Note: 'effective_permissions(user) = ∪ permissions(roles(user)). Прямых прав у пользователя нет (решение 30.09): исключения — через заведение роли' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (роль пользователя); Прил. 2 «Пользователь — Роль: многие-к-одному» +// Аналитика: md «Модель данных» v4 (user_role M:N) +// Макет: «Роль» пользователя +// В модели: v4 +// Зачем: M:N оставлен (пользователь может быть КА и Куратором); если ТЗ строго 1:1 — заменить на app_user.role_id +Table user_role { + id uuid [pk] + user_id uuid [not null, ref: > app_user.id] + role_id uuid [not null, ref: > role.id] + Indexes { (user_id, role_id) [unique] } + Note: 'M:N current-state. История назначений — audit_event(USER_ROLE_CHANGE)' +} + +// ============ ФАЙЛЫ / РАСПОЗНАВАНИЕ ============ + +// ИСТОЧНИК: +// ТЗ: 6.2 (файлы с префиксом UUID, исходные документы хранятся); 8.4 «хранение 5 лет с даты прекращения отношений, архивирование/удаление»; ЕХ 6.2 «ссылка на исходные документы с возможностью скачать», ЕХ 8.3 «архивирование в data lake» +// Аналитика: md «Модель данных» v4 (s3_key, raw_result_s3_key — строки) +// Макет: — +// В модели: v5.2 новая (DECISION-4), подтверждена в v5.4 +// Зачем: единая точка ключей всех бакетов для lifecycle/ретеншна и дедупа; без реестра ключи размазаны по 4 таблицам +Table s3_object { + id uuid [pk] + bucket_role s3_bucket_role [not null] + bucket varchar [not null] + key varchar [not null, note: 'схема ключа: {file_id}/{job_id}/{name}'] + content_type varchar + size_bytes bigint + sha256 varchar + version_id varchar [note: 'S3 object version при включённом versioning'] + created_by varchar [note: 'arm-core | tabular-parser | scan-gateway | user:{id}'] + created_at timestamptz [not null] + expires_at timestamptz [note: 'lifecycle: NULL = весь срок (ТЗ 8.4); exports/ = 7 дней; results/ — решение ARCH-010'] + archived_to varchar [note: 'ссылка в data lake после архивирования (ЕХ 8.3)'] + Indexes { (bucket, key) [unique] } + Note: 'Реестр объектов S3. stored_file / recognition_job ссылаются сюда FK' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (типы и форматы документов, UUID); 8.2 «первичные документы не редактируются, только замена с версионностью» +// Аналитика: md «Загрузка документов» (панель, UC-01…05); FNR01 «Маппинг stored_file» +// Макет: «Первичные документы», «Загрузка документов», «Замена документа», «Удаление документа» +// В модели: v4 + v5 (version_no, superseded_by) + v5.2 (s3_object_id); v5.13 — только ФИЗИЧЕСКИЙ файл, документы → document +Table stored_file { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'организация-владелец (выбрана при загрузке)'] + file_type file_type [not null] + original_name varchar + s3_object_id uuid [not null, ref: > s3_object.id, note: 'исходный файл в uploads'] + content_hash varchar [note: 'sha256, дедуп (UC-01 A2)'] + size_bytes bigint + status file_status [not null] + version_no int [not null, default: 1, note: 'ADD ТЗ 8.2: версия при замене'] + superseded_by_file_id uuid [ref: > stored_file.id, note: 'ссылка на файл-замену; статус SUPERSEDED. Замена — файл ЦЕЛИКОМ со всеми документами в нём (решение 01.10)'] + current_recognition_job_id uuid [ref: > recognition_job.id, note: 'авторитетный прогон; его результат породил актуальные document'] + uploaded_by uuid [ref: > app_user.id] + uploaded_at timestamptz + updated_at timestamptz + Indexes { + (legal_entity_id, status) + current_recognition_job_id [note: 'v5.15: проверка FK при удалении recognition_job / файла (OQ-117)'] + } + Note: 'v5.13: физический файл одной загрузки (S3 — только ссылка). Что внутри — document (1:N). Первичка не редактируется. Удаление физическое запрещено после INITIATED — только SUPERSEDED (ANL-020)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (типы документов: ББ, ОФР, ОСВ по счетам; обязательный набор); 5.1 (подход); 8.2; Прил. 2 «Документ ФО» +// Аналитика: md «Загрузка документов» (чек-лист, UC-01 A1 период, «Подтвердить данные»); md «Парсинг ОСВ» (счёт/период из шапки) +// Макет: «Чек-лист загруженных документов», «Соответствует период», «Не соответствует контрагент», «Подтвердить данные» +// В модели: v5.13 новая (замечание 6-бис) +// Зачем: один файл = N документов (PDF ББ+ОФР; xlsx с несколькими ОСВ). Чек-лист, проверки, подтверждение и +// FK разобранных данных — здесь; без этого «ОСВ 58 загружена?» по файлу не ответить +Table document { + id uuid [pk] + stored_file_id uuid [not null, ref: > stored_file.id] + legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'денормализация stored_file.legal_entity_id — выборки чек-листа по ЮЛ+период'] + document_type document_type [not null] + osv_account_code varchar [note: 'для OSV — счёт по шапке (01, 60, 62…), нормализованный; NULL для прочих типов'] + period_year smallint [note: 'из шапки/формы'] + period_quarter period_quarter + is_cumulative boolean [note: 'true = нарастающий итог с начала года'] + reporting_date date [note: 'отчётная дата, если определена'] + locator varchar [note: 'где в файле: лист xlsx / диапазон страниц PDF (напр. «sheet:ОСВ 58», «pages:1-2»)'] + status document_status [not null] + period_check document_check_result [note: 'UC-01 A1 / макет «Соответствует период»'] + counterparty_check document_check_result [note: 'макет «Не соответствует контрагент» — ИНН/наименование из шапки vs legal_entity'] + confirmed_by uuid [ref: > app_user.id, note: 'кто нажал «Подтвердить данные» → audit_event(DOCUMENT_CONFIRM)'] + confirmed_at timestamptz + created_at timestamptz + updated_at timestamptz + Indexes { + (stored_file_id, document_type, osv_account_code) [unique, note: 'NULLS NOT DISTINCT — один ББ / один ОФР / одна ОСВ по счёту на файл'] + (legal_entity_id, document_type, osv_account_code, period_year, period_quarter, status) [note: 'чек-лист required_document_rule'] + } + Note: 'Логический документ внутри файла. Создаётся ядром при маппинге результата recognition_job. Разобранные данные: OSV → osv_entry; BALANCE/INCOME → financial_statement_meta. Счётчики «исправлено полей» — COUNT(is_modified) по данным, не хранятся' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (статусы процесса распознавания, ИС Сканирование); 6.10 «повторная обработка»; 7.1 +// Аналитика: FNR01 «Маппинг recognition_job»; md «Загрузка документов» UC-02; md «Парсинг ОСВ» (схема группировок шапки) +// Макет: «Система распознавания: получен ответ», «Обработка», «Ошибка» +// В модели: v4, переписана в v5.2 под контракт NATS (DECISION-4); v5.13 — group_schema jsonb, убран raw_result +Table recognition_job { + id uuid [pk, note: 'job_id в контракте NATS'] + file_id uuid [not null, ref: > stored_file.id] + pipeline recognition_pipeline [not null, note: 'tabular → tabular-parser; image → scan-gateway'] + request_subject varchar [not null, note: 'recognition.tabular.request | recognition.image.request'] + request_msg_id varchar [unique, note: 'Nats-Msg-Id запроса (outbox)'] + result_msg_id varchar [unique, note: 'Nats-Msg-Id результата (inbox, дедуп)'] + schema_version varchar [not null, note: 'версия контракта сообщений'] + service_name varchar [note: 'tabular-parser | scan-gateway — из результата'] + service_version varchar + status recognition_status [not null] + attempt smallint [not null, default: 1] + max_attempts smallint [not null, default: 1, note: 'OCR: 1 (0 повторов), парсер: 3'] + requested_at timestamptz + published_at timestamptz + started_at timestamptz + finished_at timestamptz + reply_deadline timestamptz [note: 'таймаут ожидания (OCR — 5 мин)'] + external_task_id varchar [note: 'id задания в ИС Сканирование (только image)'] + error_code varchar [note: 'UNSUPPORTED_FORMAT | UNREADABLE | TIMEOUT | SCHEMA_MISMATCH | …'] + error_message text + result_s3_object_id uuid [ref: > s3_object.id, note: 'нормализованный JSON в results — единственная ссылка на результат (v5.13). Сырой ответ ИС Сканирование — S3 raw/{job_id}.json по конвенции, в БД не регистрируется'] + result_schema_version varchar [note: 'версия схемы JSON результата (ARCH-008/009)'] + group_schema jsonb [note: 'v5.13 (вместо osv_group_schema): схема группировок шапки ОСВ по листам — [{\"locator\":\"sheet:ОСВ 58\",\"groups\":[{\"type\":\"ACCOUNT\"},{\"type\":\"COUNTERPARTY\",\"has_inn\":true},{\"type\":\"CONTRACT\"}]}]; только для tabular'] + metrics jsonb [note: 'duration_ms, pages, rows, warnings'] + fields_total int [note: 'v5.7: всего извлечённых значений (ячейки формы / строки ОСВ)'] + fields_low_confidence int [note: 'v5.7: значений с confidence < 100 (ТЗ 6.2) — заполняется при маппинге результата'] + fields_unrecognized int [note: 'v5.7: значений с is_recognized = false'] + Indexes { (file_id, attempt) [unique] } + Note: 'РЕШЕНИЕ v5.4: единственная точка обмена с NATS. Запись = outbox: создаётся в queued в одной транзакции с stored_file, фоновый публикатор переводит в published (при сбое публикации — повтор по queued старше N сек); результат приходит по NATS, дедуп по result_msg_id UNIQUE; redelivery после max_deliver → status=dlq, replay = сброс в queued с attempt+1. Ядро маппит JSON из S3 в document → osv_entry / financial_statement_*' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (Osv_report_lines); 6.5 (строка ОСВ: компания, счёт, контрагент, признак, 6 сумм); Прил. 2 «Строка сводной ОСВ», «Распознанный показатель» +// Аналитика: FNR01 «Маппинг osv_entry»; md «Парсинг ОСВ» п.11 (row_number, level, group_type, data_type) +// Макет: «Оборотно-сальдовая ведомость»: Дебет/Кредит начало/оборот/конец, «Тип контрагента» +// В модели: v4 + v5 (owner_legal_entity_id — FIX, parent_entry_id, is_modified, is_excisable); v5.13 — document_id, group_type вместо group_schema_id +Table osv_entry { + id uuid [pk] + recognition_job_id uuid [not null, ref: > recognition_job.id] + document_id uuid [not null, ref: > document.id, note: 'v5.13: ОСВ-документ (счёт + период); файл — через document.stored_file_id'] + owner_legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'FIX: организация-ВЛАДЕЛЕЦ ОСВ (денормализация). В v4 называлось legal_entity_id с ошибочной note'] + parent_entry_id uuid [ref: > osv_entry.id, note: 'родитель в дереве строк ОСВ (ТЗ 6.5). ОТКРЫТО (замечание 2, отложено 01.10): хранить ли только контрагентские строки и нужно ли дерево'] + group_type osv_group_type [note: 'v5.13: тип строки по схеме шапки (recognition_job.group_schema) — ACCOUNT / DEPARTMENT / COUNTERPARTY / CONTRACT / OTHER'] + group_name varchar [note: 'исходное название группировки из шапки («Контрагенты», «Договоры»)'] + row_number int + level smallint + row_value varchar [note: 'исходный текст строки'] + data_type osv_data_type + account_code varchar [note: 'сырой код счёта'] + account_id uuid [ref: > chart_of_account.id] + account_root varchar + counterparty_name varchar [note: 'сырое имя из ОСВ, не изменяется'] + counterparty_inn varchar [note: 'сырой ИНН, не изменяется'] + counterparty_id uuid [ref: > legal_entity.id, note: 'резолв по ИНН/имени/алиасу или ручное сопоставление; NULL = не сопоставлен. Признак ГСК/ГК/Внешний выводится из периметра версии (DECISION-5)'] + opening_debit numeric(18,2) + opening_credit numeric(18,2) + turnover_debit numeric(18,2) + turnover_credit numeric(18,2) + closing_debit numeric(18,2) + closing_credit numeric(18,2) + is_modified boolean [not null, default: false, note: 'значения правились аналитиком'] + original_values jsonb [note: 'v5.6: суммы до первой ручной правки {opening_debit,…}; NULL = не правилось'] + modified_by uuid [ref: > app_user.id] + modified_at timestamptz + is_excisable boolean [note: 'ADD ТЗ 6.4: признак «подакцизный товар» для ОСВ 90'] + Indexes { + (document_id, row_number) [unique] + (owner_legal_entity_id, account_id, counterparty_id) + parent_entry_id [note: 'v5.15: обход дерева строк и проверка self-FK при удалении документа (OQ-117)'] + recognition_job_id [note: 'v5.15: проверка FK при удалении recognition_job / файла (OQ-117)'] + } + Note: 'Строка ОСВ. Правки сумм аналитиком — original_values/modified_by + audit_event(RECOGNIZED_DATA_EDIT, before/after)' +} + + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 «извлекаются значения по кодам форм Приказа 157н»; 6.3 проверки единиц измерения/даты +// Аналитика: FNR01 «fin_report_header» (report_type, org, unit, currency, reporting_date); md «Модель данных» v4 +// Макет: «Реквизиты организации» (ОКПО, ОКОПФ, ОКФС, ОКУД, ОКЕИ, адрес), «Единица измерения», «Отчетная дата» +// В модели: v4 (FIX pk) + v5.1 (реквизиты — ANL-044); v5.13 — document_id +Table financial_statement_meta { + id uuid [pk, note: 'FIX: был без PK'] + document_id uuid [not null, unique, ref: > document.id, note: 'v5.13: документ BALANCE/INCOME (1:1); файл — через document'] + recognition_job_id uuid [not null, ref: > recognition_job.id] + statement_type statement_type [not null] + inn varchar + inn_original_value varchar + inn_confidence numeric(5,2) + inn_verified boolean + inn_modified boolean + organization_name varchar [note: 'как в документе; сверяется с legal_entity/alias, при несовпадении — document.counterparty_check'] + report_date date + report_date_original_value varchar [note: 'FIX: строка как распознано (был `date []`)'] + report_date_verified boolean + report_date_modified boolean + units varchar [note: 'тыс. руб. / руб.'] + units_original_value varchar + units_verified boolean + units_modified boolean + currency varchar [default: 'RUB'] + okpo varchar [note: 'v5.1 ANL-044: реквизиты по макету экрана распознавания'] + okopf varchar + okfs varchar + okud varchar [note: '0710001 / 0710002'] + okei varchar + legal_form varchar [note: 'ОПФ'] + ownership_form varchar + address varchar + subject_to_audit boolean + reviewed_by uuid [ref: > app_user.id, note: 'v5.9: кто проверил данные формы; подтверждение документа — document.confirmed_by'] + reviewed_at timestamptz + checks_accepted_by uuid [ref: > app_user.id, note: 'v5.9 ТЗ 6.3 п.7: акцепт КА непройденных проверок (напр. 1370/2400) с комментарием'] + checks_accepted_at timestamptz + checks_accepted_comment text + Note: 'Шапка распознанной формы ББ/ОФР. Логические проверки (ТЗ 6.3 Таблица 1) не хранятся — считаются на лету по value' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.3 п.7 «стр. 1370 на конец и на начало года»; 6.7 «УК ДО на отчётную дату» +// Аналитика: FNR01 «rows[0].cells[2..4] — наименования колонок периода»; md «Логические проверки» («по каждой отчётной дате (колонке)») +// Макет: колонки «На 30 сентября 2025 г.», «На 31 декабря 2024 г.», «Выбор периодов» +// В модели: v5 новая (FIX-14) +// Зачем: дата колонки — без неё межформенная проверка и гудвилл не считаются +Table financial_statement_column { + id uuid [pk] + meta_id uuid [not null, ref: > financial_statement_meta.id] + column_index smallint [not null] + label varchar [note: 'как в форме: «На 30 сентября 2025 г.», «За 9 месяцев 2025 г.»'] + period_date date [note: 'ADD FIX-14: дата колонки — нужна для 1370(нач.года), гудвилла «на отчётную дату», сравнения версий'] + is_current boolean [note: 'колонка отчётного периода'] + Indexes { (meta_id, column_index) [unique] } + Note: 'Колонки-периоды формы (2–3 сравнительные даты)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (коды статей по формам 157н); 6.3 (Таблица 1 — коды строк 1100…2400) +// Аналитика: FNR01 «fin_report_rows» (row_name, row_code, section, is_total_row); md «Модель данных» v4 +// Макет: «Результат распознавания»: Наименование показателя, Код, разделы +// В модели: v4 (FIX: убраны копипаст-поля) +Table financial_statement_line { + id uuid [pk] + meta_id uuid [not null, ref: > financial_statement_meta.id] + line_code varchar [note: 'код строки ОКУД, напр. 1230'] + line_code_original_value varchar + line_code_confidence numeric(5,2) + line_code_modified boolean + line_name varchar + line_name_original_value varchar + line_name_confidence numeric(5,2) + line_name_modified boolean [note: 'FIX: был numeric'] + Indexes { (meta_id, line_code) [unique] } + Note: 'Только строки с кодом; порядок, разделы и итоговые строки — шаблон формы ФСБУ 4/2023 на фронте' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 «уверенность распознавания ниже 100% → Требуется ручная проверка»; Прил. 2 «Распознанный показатель: значение, уверенность, признак ручной корректировки» +// Аналитика: FNR01 (value_current/prior/prior2, confidence, скобки → отрицательное); md «Логические проверки» (правка ячеек) +// Макет: подсветка ячеек, «не распознано», «Редактировать/Сохранить», «Подтвердить данные» +// В модели: v4 + v5 (column_id, confidence, original_amount, modified_by); v5.13 — убран needs_review +Table financial_statement_value { + id uuid [pk] + line_id uuid [not null, ref: > financial_statement_line.id] + column_id uuid [not null, ref: > financial_statement_column.id] + amount numeric(18,2) + string_value varchar [note: 'как распознано, если не число'] + confidence numeric(5,2) [note: 'уверенность распознавания (Прил. 2 ТЗ). Подсветка «требует проверки» = confidence < порог OR NOT is_recognized — считается, не хранится (v5.13)'] + is_recognized boolean + is_modified boolean [not null, default: false] + original_amount numeric(18,2) [note: 'значение до ручной правки'] + modified_by uuid [ref: > app_user.id] + modified_at timestamptz + Indexes { (line_id, column_id) [unique] } +} + + +// ============ КОНСОЛИДАЦИЯ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 (процесс формирования КФО за отчётный период); 7.2 «формируется по требованию»; Прил. 2 «Консолидация: ГСК, отчётный период» +// Аналитика: md «Сводная ОСВ» / «Шахматка» (consolidation.year/quarter); md «Модель данных» v4 +// Макет: «Создание консолидации: ГК → ГСК → Квартал» +// В модели: v4 + v5 (reporting_date) +// Зачем: логическая консолидация ГСК+период; версии — ниже +Table consolidation { + id uuid [pk] + company_group_id uuid [not null, ref: > company_group.id, note: 'v5.9: консолидация создаётся от ГК'] + related_group_id uuid [ref: > related_group.id, note: 'v5.9: ГСК — NULL до утверждения; фиксируется/создаётся при VERIFIED по составу consolidation_member (ANL-029)'] + year smallint [not null] + quarter period_quarter [not null] + reporting_date date [not null, note: 'ADD: 01.04 / 01.07 / 01.10 / 01.01 след. года — для ЕХ и сравнений'] + created_at timestamptz + Indexes { (company_group_id, year, quarter) [unique] } + Note: 'Логическая консолидация ГК за период; версии различаются периметром (consolidation_member). Прошлые периоды — по company_group_id' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 (сценарий, подход, чекбокс Гудвилл); 6.10 (статусная модель, маршрут по инициатору, № и дата решения УОБ, «новая версия КФО» после Верифицирован); 6.4 (причины отклонений от Методики) +// Аналитика: md «Статусная модель»; md «Модель данных» v4 (consolidation_version: version_no, status varchar, initiator, approved_in_rf, verified_in_go, kfo_confirmation_enabled) +// Макет: «Версия», «Дата верификации», «Включить/Отключить подтверждение КФО», «Смена статуса отчёта», «Ответственный / Утвердил / Верифицировал» +// В модели: v4 + v5 (enum статусов, scenario, approach, goodwill_enabled, initiator_office_id, uob_*, methodology_deviations, previous_version_id) +Table consolidation_version { + id uuid [pk] + consolidation_id uuid [not null, ref: > consolidation.id] + version_no int [not null] + status consolidation_status [not null, note: 'CHG: enum вместо varchar'] + scenario consolidation_scenario [not null, note: 'ADD ТЗ 5.1'] + approach consolidation_approach [not null, default: 'BASIC', note: 'ADD ТЗ 5.1'] + goodwill_enabled boolean [not null, default: false, note: 'ADD ТЗ 5.1/6.7: чек-бокс расчёта Гудвилла'] + initiator_id uuid [not null, ref: > app_user.id] + initiator_office_id uuid [not null, ref: > regional_office.id, note: 'ГО/РФ инициатора — определяет маршрут (ТЗ 6.10)'] + approved_in_rf_by uuid [ref: > app_user.id] + approved_in_rf_at timestamptz + verified_in_go_by uuid [ref: > app_user.id] + verified_in_go_at timestamptz + uob_decision_no varchar [note: 'ADD ТЗ 6.10: № решения УОБ'] + uob_decision_date date [note: 'ADD ТЗ 6.10'] + methodology_deviations text [note: 'ADD ТЗ 6.4: причины отклонений от Методики'] + elimination_rules_version int [note: 'v5.11: версия elimination_rule (version_no), по которой считалась версия; фиксируется при первом расчёте, замораживается при VERIFIED'] + is_in_work boolean [not null, default: true] + previous_version_id uuid [ref: > consolidation_version.id, note: '«новая версия КФО» после VERIFIED'] + cancelled_reason text + created_at timestamptz + created_by uuid [ref: > app_user.id] + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { (consolidation_id, version_no) [unique] } + Note: 'Версия консолидации. После VERIFIED — read-only; изменения только через новую версию' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 (переходы, возврат на доработку); 8.2 «изменение статусов; согласование и утверждение КФО» +// Аналитика: md «Статусная модель» (переходы) +// Макет: «Смена статуса отчёта», «Комментарий (обязательно)», «Отклонено: отчёт возвращён на доработку» +// В модели: v5 новая +Table consolidation_status_history { + id uuid [pk] + version_id uuid [not null, ref: > consolidation_version.id] + from_status consolidation_status + to_status consolidation_status [not null] + changed_by uuid [not null, ref: > app_user.id] + changed_at timestamptz [not null] + comment text [note: 'обязателен при возврате на доработку'] + Note: 'ADD ТЗ 6.10/8.2: маршрут согласования' +} + +// ADD v5.1 (макет, ANL-036): «взять в работу» / перехват прав редактирования +// ИСТОЧНИК: +// ТЗ: нет в ТЗ (ANL-036) +// Аналитика: нет +// Макет: «Взятие КФО в работу (перехват прав редактирования)», «Причина: Отпуск», «В данный момент консолидация редактируется пользователем …» +// В модели: v5.1 новая (макет) +// Зачем: блокировка версии; подтвердить с заказчиком +Table consolidation_lock { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + locked_by uuid [not null, ref: > app_user.id] + locked_at timestamptz [not null] + released_at timestamptz [note: 'NULL = активная блокировка'] + taken_over_from uuid [ref: > app_user.id, note: 'при перехвате — предыдущий владелец'] + takeover_reason text [note: 'обязателен при перехвате (макет: «Причина: Отпуск»)'] + Indexes { consolidation_version_id [note: 'partial UNIQUE WHERE released_at IS NULL'] } + Note: 'Ответственный за версию сейчас = активная блокировка' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 «КА в рамках консолидации редактирует состав ГСК»; 6.10 «выбор состава ГСК»; ЕХ 6.3 «Периметр на дату» +// Аналитика: md «Сводная ОСВ» п.2 («зафиксированный периметр; изменение related_group_member не меняет версию»); md «Модель данных» v4 +// Макет: «Состав ЮЛ», «Минимум для консолидации: 2», «Редактировать состав» +// В модели: v4 + v5 (is_head_entity для Гудвилла) +Table consolidation_member { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + is_head_entity boolean [not null, default: false, note: 'ADD: головная организация (для Гудвилла)'] + Indexes { (consolidation_version_id, legal_entity_id) [unique] } + Note: 'Зафиксированный периметр версии. Изменение related_group_member не меняет существующие версии' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Документ ФО: ссылка на Консолидацию и ЮЛ»; 6.4/6.5 «использование данных предыдущих периодов»; ЕХ 7 «повторное использование первички без дублей» +// Аналитика: md «Сводная ОСВ» п.3 (файлы выбираются по ЮЛ+год+квартал — без явной связи) +// Макет: «Первичные документы консолидации» +// В модели: v5 новая; v5.13 — document_id вместо stored_file_id +// Зачем: явная привязка документа к версии (переиспользование, монозаёмщики из ЕХ) +Table consolidation_document { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + document_id uuid [not null, ref: > document.id, note: 'v5.13: логический документ (ОСВ по счёту / ББ / ОФР), не файл'] + Indexes { (consolidation_version_id, document_id) [unique] } + Note: 'ADD Прил. 2 ТЗ «Документ ФО → Консолидация»: явная привязка документов к версии (переиспользование в нескольких версиях/периодах, монозаёмщики из ЕХ). Чек-лист = required_document_rule × периметр − consolidation_document' +} + + + + +// ---- Корректировки ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.8 (форма корректировок: колонки ДБ/КР/Комментарий, >5%, импорт/экспорт, отдельная форма ОФР); 6.4 (доп. корректировки ОФР); Прил. 2 «Расхождение/корректировка»; Прил. 9 +// Аналитика: md «Корректировка данных» (correction: parent_correction_id, debit/credit_line_code, adjustment); md «Модель данных» v4 +// Макет: «Таблица корректировки»: Актив/Пассив/Контроль/Корректировка, «Строка баланса Дт/Кт», «Комментарий» +// В модели: v4 + v5 (statement_type, pair_match_id, reason_code, comment NOT NULL, status, version_no) +Table correction { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + statement_type statement_type [not null, note: 'ADD: ББ или ОФР (ТЗ 6.8 — отдельные формы)'] + rule_id uuid [ref: > elimination_rule.id, note: 'v5.10: пара ВГО по естественному ключу (правило + компании); NULL = ручная без пары'] + parent_correction_id uuid [ref: > correction.id, note: 'дочерняя строка распределения по нескольким статьям; глубина 1'] + legal_entity_id uuid [ref: > legal_entity.id, note: 'компания 1 (чья первичка корректируется)'] + counterparty_id uuid [ref: > legal_entity.id, note: 'компания 2'] + debit_line_code varchar + debit_account_id uuid [ref: > chart_of_account.id] + debit_adjustment numeric(18,2) + credit_line_code varchar + credit_account_id uuid [ref: > chart_of_account.id] + credit_adjustment numeric(18,2) + deviation_amount numeric(18,2) + deviation_percent numeric(9,4) + reason_code varchar [note: 'ADD ТЗ 6.4: unrealized_revenue | excise | agency_commission | intra_lease_depreciation | intra_loan_interest | equity_income | other'] + comment text [not null, note: 'ТЗ 6.8: комментарий обязателен'] + status correction_status [not null, default: 'DRAFT'] + version_no int [not null, default: 1, note: 'сноска 22 ТЗ: история версий корректировки — audit_event(CORRECTION_EDIT, before/after); инкремент при каждом изменении'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { + consolidation_version_id + (consolidation_version_id, rule_id, legal_entity_id, counterparty_id) + } + Note: 'Overlay поверх первички: итог = свод первички − Σ применённых корректировок. Расхождение пары (deviation_*) — справочно, пересчитывается' +} + + +// ---- Гудвилл (ТЗ 6.7, Прил. 3 п.2, Прил. 8) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.7 (Гудвилл: ФВ ГО − доля в капитале ДО, комментарий обязателен, отдельная строка ББ); Прил. 3 п.2 (58.01 против 80/83, стр. 1310/1350/1370); Прил. 8 +// Аналитика: md «Гудвилл» (шаги 1–10: 58.01, стр. 1310, доля владения, include_in_kfo); md «Модель данных» v4 (goodwill) +// Макет: «Таблица гудвилла», «Доля владения», «Учет в отчете», «Изменение долей владения (гудвилл)» +// В модели: v4 + v5 (additional_capital_amount, comment) +Table goodwill { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + head_legal_entity_id uuid [not null, ref: > legal_entity.id] + subsidiary_legal_entity_id uuid [not null, ref: > legal_entity.id] + ownership_share numeric(7,4) [note: 'ручной ввод, %'] + financial_investment_amount numeric(18,2) [note: 'ΣДБ 58.01 ГО по ДО'] + charter_capital_amount numeric(18,2) [note: 'стр. 1310 ББ ДО на отчётную дату'] + additional_capital_amount numeric(18,2) [note: 'ADD ТЗ Прил. 3: стр. 1350 ББ ДО'] + participation_amount numeric(18,2) [note: '(1310 + 1350) * share / 100 — DECISION: уточнить у методологов, входит ли 1350 в базу'] + goodwill_amount numeric(18,2) + include_in_kfo boolean [not null, default: false] + comment text [note: 'ТЗ 6.7: обязателен при include_in_kfo'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { (consolidation_version_id, head_legal_entity_id, subsidiary_legal_entity_id) [unique] } +} + +// ---- Результат КФО (ТЗ 6.4, Прил. 10) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 (итоговая форма КФО: подход, Гудвилл, расхождения, корректировки, верификация); 6.8 (контрольные пороги 10% ЧА / 1% ВБ, 10% прибыли / 1% выручки); Прил. 10 +// Аналитика: нет (пробел — ANL-008/009) +// Макет: «КФО», «Сводка расхождений КФО», «Есть несохранённые изменения в КФО» +// В модели: v5 новая +// Зачем: шапка результата + пороги +Table kfo_result { + id uuid [pk] + consolidation_version_id uuid [not null, unique, ref: > consolidation_version.id] + computed_at timestamptz [not null] + is_frozen boolean [not null, default: false, note: 'true после VERIFIED'] + net_assets numeric(18,2) [note: 'ЧА ГСК'] + balance_total numeric(18,2) [note: 'ВБ ГСК'] + revenue numeric(18,2) + sales_profit numeric(18,2) + total_deviation_bb numeric(18,2) [note: 'Σ расхождений по статьям ББ'] + total_deviation_ofr numeric(18,2) + threshold_bb_exceeded boolean [note: 'ТЗ 6.8: > 10% ЧА (или > 1% ВБ, если ЧА < 10% ВБ)'] + threshold_ofr_exceeded boolean [note: '> 10% прибыли от продаж (или > 1% выручки при рентабельности < 10%)'] + threshold_justification text [note: 'обоснование принятия риска при превышении'] + goodwill_total numeric(18,2) + has_deviations boolean + Note: 'Шапка результата КФО + контрольные пороги' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 «шаг 1 — сложение по кодам строк; шаг 2 — элиминация; неэлиминируемая часть остаётся»; Прил. 2 «Результат КФО: статья, значение»; Прил. 10; ФСБУ 4/2023 +// Аналитика: нет (пробел — ANL-008) +// Макет: колонки «в т.ч. взаимные обязательства», «Очищенные показатели», «Расхождения», «источник: Баланс/ОСВ», «в т.ч. Гудвилл» +// В модели: v5 новая + v5.1 (vgo_amount, source_kind, deviation_amount, comment) +Table kfo_result_line { + id uuid [pk] + kfo_result_id uuid [not null, ref: > kfo_result.id] + statement_type statement_type [not null] + line_code varchar [not null, note: 'код строки ФСБУ 4/2023; GOODWILL — отдельная строка раздела I'] + line_name varchar + sum_amount numeric(18,2) [note: 'шаг 1: простое сложение по компаниям'] + eliminated_auto numeric(18,2) [note: 'шаг 2: авто-элиминация по правилам'] + eliminated_manual numeric(18,2) [note: 'Σ применённых корректировок'] + goodwill_adjust numeric(18,2) + consolidated_amount numeric(18,2) [not null, note: 'итог («Очищенные показатели» в макете)'] + vgo_amount numeric(18,2) [note: 'v5.1 макет: «в т.ч. взаимные обязательства/обороты» = eliminated_auto + eliminated_manual'] + source_kind varchar [note: 'v5.1 макет: balance | osv — источник значения статьи'] + deviation_amount numeric(18,2) [note: 'v5.1 макет «Расхождения» по статье'] + comment text [note: 'v5.1 макет: пояснение к строке КФО'] + ordinal int + Indexes { (kfo_result_id, statement_type, line_code) [unique] } +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.6 «выгрузка расшифровки статей отчётности в разрезе контрагентов»; ТЗ 6.9 (настраиваемые поля экспорта) +// Аналитика: нет +// Макет: «Доля в показателе по группе, %», расшифровка по компаниям +// В модели: v5 новая + v5.1 (share_percent) +Table kfo_result_line_company { + kfo_result_line_id uuid [not null, ref: > kfo_result_line.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + amount numeric(18,2) + share_percent numeric(7,4) [note: 'v5.1 макет: «Доля в показателе по группе, %»'] + Indexes { (kfo_result_line_id, legal_entity_id) [pk] } + Note: 'Разрез по компаниям для расшифровки статьи (ЕХ 6.6 «расшифровка в разрезе контрагентов»)' +} + +// ---- Сценарий «Подтверждение КФО клиента» (ТЗ 5.1, 6.10) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 «подтверждение консолидированной отчётности, предоставленной Клиентом»; 6.10 «ручной ввод данных КФО клиента; сравнение и анализ расхождений»; Методика сноска 14 (процент к наименьшему) +// Аналитика: md «Статусная модель» (kfo_confirmation_enabled — только флаг; ANL-006) +// Макет: «Подтверждение КФО клиента»: Версия клиента / Версия банка, ВГО клиента / ВГО Банка, Расхождение, Причина +// В модели: v5 новая + v5.1 (client_vgo/bank_vgo/deviation_reason) +Table client_kfo_line { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + statement_type statement_type [not null] + line_code varchar [not null] + client_amount numeric(18,2) [note: 'ручной ввод из КФО клиента («Консолидированная отчетность от клиента»)'] + client_vgo_amount numeric(18,2) [note: 'v5.1 макет: «ВГО клиента» — ручной ввод'] + bank_amount numeric(18,2) [note: 'из kfo_result_line на момент сравнения'] + bank_vgo_amount numeric(18,2) [note: 'v5.1 макет: «ВГО Банка» из kfo_result_line.vgo_amount'] + deviation_amount numeric(18,2) + deviation_percent numeric(9,4) [note: 'относительно наименьшего (Методика сноска 14)'] + deviation_reason text [note: 'v5.1 макет: «Причина расхождения»'] + comment text + entered_by uuid [ref: > app_user.id] + entered_at timestamptz + Indexes { (consolidation_version_id, statement_type, line_code) [unique] } +} + + + +// ============ КОММЕНТАРИИ, АУДИТ, ЭКСПОРТ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.3 «возможность добавления комментария к отчётности»; 6.4 «комментарии» в форме КФО; 6.5/6.8 «обязательный комментарий» +// Аналитика: md «Загрузка документов» («Комментарий к документу») +// Макет: «Новый комментарий», «Комментарий к документу», комментарии в ячейках шахматки, «Редактировать комментарии» в КФО +// В модели: v5 новая +// Зачем: полиморфные комментарии к объектам +Table comment { + id uuid [pk] + object_type varchar [not null, note: 'document | consolidation_version | chess_cell(ключ пары) | kfo_result_line | …'] + object_id uuid [not null] + text text [not null] + author_id uuid [not null, ref: > app_user.id] + created_at timestamptz [not null] + Indexes { (object_type, object_id) } + Note: 'ADD ТЗ 6.3/6.4: комментарии к отчётности и КФО (в т.ч. обязательные при расхождениях)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.2 (журнал изменений, логирование всех действий, перечень событий: вход/выход, создание консолидации, ГСК/ГК, загрузка, распознанные данные, корректировки, статусы, утверждение, выгрузка; хранение ≥ срока данных); Прил. 2 «Запись журнала изменений» +// Аналитика: нет (пробел — ANL-019); md «Ролевая модель» ссылается на «журнал аудита» +// Макет: «Журнал действий»: разделы, Объект/Событие/Что изменилось/Результат/Идентификатор, IP/браузер, «неверный пароль» +// В модели: v5 новая + v5.1 (section, result, changed_field, user_agent) +Table audit_event { + id uuid [pk] + occurred_at timestamptz [not null] + user_id uuid [ref: > app_user.id, note: 'NULL для системных событий'] + action audit_action [not null] + section audit_section [not null, note: 'v5.1 макет: раздел журнала'] + object_type varchar [not null] + object_id uuid + consolidation_version_id uuid [ref: > consolidation_version.id, note: 'для выборки «история изменений КФО»'] + description text + before_value jsonb + after_value jsonb + comment text + result varchar [note: 'v5.1 макет: Успешно / Отклонено / Ошибка'] + changed_field varchar [note: 'v5.1 макет: фильтр «Изменяемое поле»'] + ip_address varchar + user_agent varchar [note: 'v5.1 макет: браузер в событиях сеанса'] + Indexes { + (consolidation_version_id, occurred_at) + (object_type, object_id) + (user_id, occurred_at) + } + Note: 'ТЗ 8.2: журнал изменений/аудит. Append-only. Хранение ≥ срока хранения консолидации. Также покрывает: историю правок распознанных данных, факт/состав выгрузок (ЕХ 6.6), импорт справочников, изменения матрицы прав' +} + + + +// ============ ЕХФД (ТЗ BIQ-34649) ============ +// DECISION-2: срез = замороженный указатель на верифицированную версию + список её артефактов. + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.2 «запись срезами: одна ГСК на одну отчётную дату, после Верифицирован»; 6.4 «версии среза Актуальная/Архив, неизменяемость»; 5.3 «архивирование в data lake» +// Аналитика: нет (пробел — ANL-032) +// Макет: «Единое хранилище», «Сформированная отчетность», «Верифицирована» +// В модели: v5 новая (DECISION-2) +// Зачем: замороженный указатель на верифицированную версию +Table ex_snapshot { + id uuid [pk] + related_group_id uuid [not null, ref: > related_group.id] + company_group_id uuid [not null, ref: > company_group.id, note: 'денормализация на момент верификации'] + reporting_date date [not null] + year smallint [not null] + quarter period_quarter [not null] + version_no int [not null] + status ex_snapshot_status [not null, note: 'ACTUAL — последняя верифицированная; предыдущие → ARCHIVE'] + source_version_id uuid [not null, unique, note: 'v5.12: id consolidation_version-источника БЕЗ FK — источник удаляется по сроку, срез нет'] + source_version_no int [not null] + superseded_by_snapshot_id uuid [ref: > ex_snapshot.id, note: 'v5.12: для ARCHIVE — ссылка на актуальный срез, который его заместил'] + verified_at timestamptz [not null] + written_at timestamptz [not null] + written_by uuid [ref: > app_user.id, note: 'инициатор записи'] + archived_at timestamptz + data_lake_ref varchar [note: 'ссылка на архив в data lake'] + Indexes { + (related_group_id, year, quarter, version_no) [unique] + (related_group_id, year, quarter, status) + } + Note: 'Срез данных ЕХ: одна ГСК на одну отчётную дату (ЕХ 6.2). Неизменяем и самодостаточен (не зависит от таблиц консолидации). Состав — ex_snapshot_item + ex_snapshot_member' +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.3 «Периметр на дату: состав ЮЛ/ИП группы на отчётную дату» +// Аналитика: нет +// Макет: дерево «ГК → ГСК → Организации» с ИНН +// В модели: v5 новая +// Зачем: денормализация периметра на дату среза +Table ex_snapshot_member { + ex_snapshot_id uuid [not null, ref: > ex_snapshot.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + inn varchar [not null, note: 'денормализация на дату среза'] + name varchar [not null] + Indexes { (ex_snapshot_id, legal_entity_id) [pk] } + Note: 'Периметр на дату (ЕХ 6.3)' +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 5.2 «Состав хранимых данных» (КФО, первичные, сводная ОСВ, шахматка, гудвилл, корректировки, иные отчёты); 6.7.3/6.7.4 «Сформированная отчётность / Первичные документы», «Просмотр / Выгрузка» +// Аналитика: нет +// Макет: область документов ЕХ, «Скачать», «Связать» документ с организацией +// В модели: v5 новая; v5.13 — document_id вместо stored_file_id +// Зачем: состав среза со ссылками на объекты +Table ex_snapshot_item { + id uuid [pk] + ex_snapshot_id uuid [not null, ref: > ex_snapshot.id] + item_type varchar [not null, note: 'primary_document | summary_osv | vgo_pairs | chess | kfo_balance | kfo_income | goodwill | corrections | other_report'] + legal_entity_id uuid [ref: > legal_entity.id, note: 'для первички — организация'] + document_id uuid [ref: > document.id, note: 'v5.13: только для primary_document — исходники хранятся весь срок, FK допустим; «Скачать» — через document.stored_file → s3_object'] + payload jsonb [note: 'v5.12 DECISION-5: замороженные данные для всех остальных типов; самодостаточно, без ссылок на таблицы консолидации'] + payload_schema_version varchar + Indexes { (ex_snapshot_id, item_type) } + Note: 'Состав среза: primary_document → document; всё остальное → payload. «Просмотр» в ЛК ЕХ рендерит payload по схеме; «Выгрузка» собирает xlsx из payload на лету' +} + +// ============ ПРОЧЕЕ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 (базовый / расширенный пакет ОСВ); 6.2 «набор обязательных ОСВ»; сноска 12 «процесс останавливается, если отсутствует документ» +// Аналитика: md «Загрузка документов» (чек-лист: «ОСВ по обязательным счетам», «прочие документы») +// Макет: «Чек-лист загруженных документов», «Условные обозначения статусов» +// В модели: v5 новая +// Зачем: конфигурация чек-листа по подходу; с v5.13 сверяется с document (тип + счёт + период), а не с файлом +Table required_document_rule { + id uuid [pk] + approach consolidation_approach [not null] + document_type document_type [not null] + account_id uuid [ref: > chart_of_account.id, note: 'для OSV'] + is_mandatory boolean [not null, note: 'обязателен → блокирует переход к консолидации (сноска 12 ТЗ)'] + Indexes { (approach, document_type, account_id) [unique] } + Note: 'ADD ТЗ 5.1/6.2: состав обязательных документов по подходу — основа чек-листа. Выполнено = есть document CONFIRMED с таким типом/счётом за период у каждого ЮЛ периметра' +} diff --git a/model/entities-v5.15.md b/model/entities-v5.15.md new file mode 100644 index 0000000..2d60142 --- /dev/null +++ b/model/entities-v5.15.md @@ -0,0 +1,155 @@ +# АРМ КА — описание сущностей модели данных v5.15 + +Источник истины по структуре — `data-model-v5.15.dbml` (полный). Диаграммы по частям — `diagrams/` (part01…09 svg/png; отрисованы по v5.13: v5.14 меняет только `related_group_member` — колонку `kind` и индекс, v5.15 — одно значение enum и три индекса, — диаграммы не перерисовывались). Здесь — что каждая таблица значит для бизнеса, как связана с соседями и откуда взялась. + +## Что изменилось в v5.15 (Срез 2c, решения владельца 05.10.2026) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `osv_data_type` + `NU` (после `BU`) | Строки налогового учёта из ОСВ «БУ, НУ» хранятся в `osv_entry` как есть (OQ-45); контракт результата допускал `NU` с v1.0, ядро раньше такой строки принять не могло | +| 2 | `app_user.email` — в нижнем регистре | Без изменения структуры: нормализация при записи (seed, администрирование), поиск по `lower(email)` сохраняется, уникальность — существующий UNIQUE по `email` (OQ-59). Expression-индекс `lower(email)` не вводится: сверка ORM↔DBML не умеет expression-индексы. Миграция `0003` приводит существующие email к нижнему регистру (при дублях по регистру — падает, слияние решает человек) | +| 3 | Семантика `stored_file.status`, `recognition_job.group_schema` | Без изменения структуры: `PROCESSING` ставит inbox по ack сервиса, `PROCESSED`/`ERROR` — цикл маппинга результата (OQ-67); маппинг выполняется отдельно от inbox (OQ-69); `group_schema` заполняет маппер; «Итого» в `osv_entry` не хранится (OQ-01) | +| 4 | Индексы `osv_entry (parent_entry_id)`, `osv_entry (recognition_job_id)`, `stored_file (current_recognition_job_id)` | Индексы для удаления файла и обхода дерева строк: без них проверки FK (в том числе self-FK `parent_entry_id`) при `DELETE /files/{id}` и очистке файла перед повторным маппингом шли seq scan'ом. Замер: DELETE документа 1000 строк 8,6 с → 16 мс на 200 тыс. строк. Обычные (не unique) индексы; закрывает OQ-117 (I-1 финального ревью Среза 2c) | + +Остальные таблицы и enum — как в v5.14 (43 таблицы, 27 enum). Реализация — миграция `0003` в `arm-core`. + +## Что изменилось в v5.14 (Срез 1, решения владельца 04.10.2026) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `related_group_member.kind` + индекс `(legal_entity_id, kind) WHERE is_current` | ЮЛ состоит **одновременно в одной ГСК и одной ГСЗ** (но только в одной группе каждого вида). Отменяет ANL-030 / v5.8 («только в одной ГСК или ГСЗ»). `kind` — денормализация `related_group.kind` (равенство проверяет ядро при записи, тест на рассинхрон); индекс не выразить через join, триггер не выбран. Закрывает OQ-32 | +| 2 | КПП | **Не вводится.** Поле есть только в макете; у `legal_entity` его нет, импорт его не читает (закрывает OQ-31) | +| 3 | Семантика `company_group.crm_id`, `base_regional_office_id` | Без изменения структуры: `crm_id` = «iD ГК» выгрузки ЕЦП CRM, `base_regional_office_id` = «Базовый РФ ГК в CRM»; флаги «входит в ГСК/ГСЗ» выгрузки → членство в одной неявной ГСК/ГСЗ на ГК (правила — `05-domain-rules.md` §13) | +| 4 | `audit_action` + `LEGAL_ENTITY_CHANGE`, `USER_CHANGE` | Журнал изменений ЮЛ (создание/изменение/закрытие/перевод, алиасы) и пользователей (создание/деактивация администратором) — ТЗ 8.2; в v5.13 для них не было действия (были `GROUP_CHANGE`, `MEMBERSHIP_CHANGE`, `USER_ROLE_CHANGE`) | + +Остальные таблицы и enum — как в v5.13 (43 таблицы). Реализация — миграция `0002` в `arm-core`. + +## Что изменилось в v5.13 (ревью 30.09–01.10) + +| № | Замечание | Решение | +|---|---|---| +| 1 | Вместо RBAC «в коде» — роли **и** права | `permission` + `role_permission` возвращены; права только через роли, прямых назначений пользователю нет. Исключения — заведением роли | +| 2 | `osv_entry.parent_entry_id` не нужен — храним только строки с контрагентами | **Отложено.** Открыт вопрос: хранить ли строки уровня ACCOUNT для сверки «Σ контрагентов = итог счёта»; аналитика «Парсинг ОСВ» п.11 пока говорит «все строки» | +| 3 | `osv_group_schema` не особо нужна | Убрана. Схема шапки — `recognition_job.group_schema jsonb`; тип строки — `osv_entry.group_type` | +| 4 | `financial_statement_value.needs_review` непонятен | Убран: производное от `confidence` / `is_recognized`, порог может меняться | +| 5 | В `recognition_job` не нужны и `result`, и `raw_result` | Оставлен только `result_s3_object_id`. Сырой ответ ИС Сканирование — S3 `raw/{job_id}.json` по конвенции, в БД не регистрируется | +| 6 | `stored_file.fields_modified` — что это | Счётчик «исправлено полей» с карточки документа (v5.7). Убран — `COUNT(is_modified)` по данным | +| 6-бис | `document_type` не предусматривает несколько ОСВ или ББ+ОФР в одном файле | **Разделение файл / документ**: `stored_file` — физический файл, `document` — логическая единица (1:N). Чек-лист, проверки, подтверждение, FK разобранных данных, `consolidation_document`, `ex_snapshot_item` — на `document`. Замена (`superseded_by`) — файл целиком | +| 7 | `file_type`: убрать Р7 | Убран. `xls/xlsx/xlsb → tabular-parser`, `pdf/png/jpg → scan-gateway` | + +**Принципы модели (коротко):** +- Первичка не редактируется: файлы и распознанные значения хранятся как пришли; правки аналитика — рядом (`is_modified`, `original_*`) и в аудите. +- Производные консолидации в работе (сводная ОСВ, пары ВГО, шахматка) **не хранятся** — считаются запросом. Хранятся только ручные решения (корректировки, гудвилл, подтверждение КФО клиента). +- При верификации всё замораживается в Единое хранилище (ЕХ) как самодостаточный срез, который не зависит от таблиц консолидации. +- Единственный писатель в БД — ядро `arm-core`; сервисы распознавания видят только NATS и S3. +- Права — данные (`role_permission`), ограничения по ЗО/филиалу/статусу — policy-слой поверх прав. + +--- + +## Часть 01 — Группы компаний, организации, филиалы + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `regional_office` | Филиал банка: головной офис (ГО, `is_head_office`) и региональные филиалы (РФ). Определяет зону видимости пользователя и закрепление ГК. | ← `company_group.base_regional_office_id`, `legal_entity.regional_office_id`, `app_user.regional_office_id` | ТЗ 8.1 | +| `company_group` | Группа компаний (ГК) — верхний уровень справочника клиентов. Текущее состояние (SCD4). `crm_id` = «iD ГК» из ЕЦП CRM (NULL, если создана вручную Куратором; v5.14 — семантика по выгрузке), внутренний `code`, закрепление за филиалом (`base_regional_office_id` = «Базовый РФ ГК в CRM»), флаг архива. | → `regional_office`; ← `legal_entity`, `related_group`, `consolidation` | ТЗ 6.1, Прил. 2; макет | +| `company_group_history` | Прошлые снимки ГК: каждое изменение — новая строка с `valid_from/valid_to`. | → `company_group` | ТЗ 6.10, 8.2 | +| `legal_entity` | Юридическое лицо / ИП. Натуральный ключ — ИНН. `company_group_id` = текущая ГК; **NULL = монозаёмщик**. `close_date` — закрыто/архивировано. | → `company_group`, `regional_office`; ← `related_group_member`, `stored_file`, `document`, `osv_entry.counterparty_id`, `consolidation_member` | ТЗ 6.1, Прил. 2; ЕХ 6.1 | +| `legal_entity_history` | Прошлые снимки ЮЛ + вид события (создание, изменение, переход в другую ГК, закрытие, вывод из ГК) и дата вступления в силу. | → `legal_entity` | ТЗ 8.2; макет | +| `legal_entity_alias` | Словарь написаний контрагента: как имя встречается в ОСВ и нормализованный ключ для матчинга. Пополняется при ручном сопоставлении. | → `legal_entity` | ТЗ 6.5 | +| `related_group` | ГСК или ГСЗ внутри ГК; `kind` различает. Для ГСК формируется КФО. Иерархия не поддерживается. | → `company_group`; ← `related_group_member`, `consolidation.related_group_id` | ТЗ 6.1, Прил. 2 | +| `related_group_member` | Состав ГСК/ГСЗ — история членства (SCD2). **v5.14:** ЮЛ может одновременно входить в **одну ГСК и одну ГСЗ** (не более одной группы каждого вида): колонка `kind` = `related_group.kind` (денормализация) и частичный уникальный индекс `(legal_entity_id, kind) WHERE is_current`. Решение владельца 04.10.2026, отменяет ANL-030. | → `related_group`, `legal_entity` | ТЗ 6.1; макет [79] | + +## Часть 02 — Пользователи, роли, права + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `app_user` | Пользователь АРМ. Аутентификация внешняя (РАИСА/SSO, `external_id`). Филиал (ГО/РФ), зона ответственности (ДКБ/ДМСБ/ДМ), табельный номер. Видимость данных = права роли + филиал + ЗО. | → `regional_office`; ← `user_role`, все `*_by` поля | ТЗ 8.1, Прил. 2 | +| `role` | Роли: seed из ТЗ 8.1 (КА РФ, КА ГО, РКА, КМ, Куратор ГО, Эксперт, Администратор; `is_system`) + роли, заведённые администратором для исключений. **v5.13:** код роли в бизнес-логике не проверяется — только права. | ← `user_role`, `role_permission` | ТЗ 8.1 | +| `permission` | **v5.13.** Атомарное право «объект × действие»: `DIRECTORY/DOCUMENT/CONSOLIDATION/KFO/EX/EXPORT/USERS/AUDIT` × `VIEW/EDIT/APPROVE/VERIFY/DELETE/EXPORT/ADMIN`. Seed, привязан к коду — администратор новые коды не создаёт. Право говорит «может»; policy-слой (ЗО, филиал, статус КФО) — «эту ли и сейчас ли». | ← `role_permission` | ТЗ Прил. 2, сноска 29 | +| `role_permission` | **v5.13.** Матрица доступов как данные: `effective_permissions(user) = ∪ permissions(roles(user))`. Редактируется администратором на экране «Изменение ролей и прав доступа»; история — `audit_event(ROLE_PERMISSION_CHANGE)`. Прямых прав у пользователя нет. | → `role`, `permission` | ТЗ Прил. 2; макет | +| `user_role` | Назначение ролей пользователю (M:N). История — в аудите. | → `app_user`, `role` | ТЗ 8.1 | + +## Часть 03 — Бухгалтерские справочники и правила + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `chart_of_account` | План счетов с иерархией, тип A/P/AP, признак забалансового. Seed — полный план 1С; нестандартные субсчета клиентов добавляет администратор. | ← `osv_entry.account_id`, `elimination_rule_account`, `required_document_rule` | ТЗ 6.2/6.5, Прил. 3/4 | +| `elimination_rule` | Правило элиминации из Прил. 3 (ББ) / Прил. 4 (ОФР) как данные: пара статей, сторона, мера, режим, НДС, акцизы. Версионируется. | ← `elimination_rule_account`, `correction.rule_id`; версия фиксируется в `consolidation_version` | ТЗ 6.4, Прил. 3/4 | +| `elimination_rule_account` | Счета/субсчета каждой стороны правила, включая исключения («76 кроме 76.7.1»). | → `elimination_rule`, `chart_of_account` | ТЗ Прил. 3/4 | +| `required_document_rule` | Какие документы обязательны по подходу. **v5.13:** сверяется с `document` (тип + счёт + период), а не с файлом: выполнено = есть `document CONFIRMED` у каждого ЮЛ периметра. | → `chart_of_account` | ТЗ 5.1, 6.2, сноска 12 | + +## Часть 04 — Файлы, документы, S3, задания распознавания + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `s3_object` | Реестр всех объектов в S3: бакет-роль, ключ, размер, sha256, срок жизни, ссылка на архив в data lake. Единая точка ретеншна (5 лет по ТЗ 8.4) и дедупа. | ← `stored_file`, `recognition_job` | ТЗ 6.2, 8.4; ЕХ 8.3 | +| `stored_file` | **v5.13: только физический файл** одной загрузки: чей (ЮЛ), формат, hash, кто/когда загрузил, статус обработки (`UPLOADED → PROCESSING → PROCESSED / ERROR / SUPERSEDED`), версия при замене. Что внутри файла — `document`. Замена — файл целиком со всеми документами. | → `legal_entity`, `s3_object`, `recognition_job`; ← `document`, `recognition_job` | ТЗ 6.2, 8.2; UC-01…05 | +| `document` | **v5.13, новая.** Логический документ внутри файла: ББ, ОФР, ОСВ по счёту (`osv_account_code`), расшифровка, прочее. Период, отчётная дата, где в файле (`locator`: лист / страницы), статус проверки (`RECOGNIZED / NEEDS_CHECK / CONFIRMED / ERROR`), результаты проверок периода и контрагента, кто подтвердил. Один файл → N документов (PDF с ББ+ОФР; xlsx с несколькими ОСВ). Создаёт ядро при маппинге результата `recognition_job`. На документ ссылаются разобранные данные, чек-лист, консолидация и ЕХ. | → `stored_file`, `legal_entity`, `app_user`; ← `osv_entry`, `financial_statement_meta`, `consolidation_document`, `ex_snapshot_item` | ТЗ 6.2, 5.1, 8.2; макет (чек-лист, «Подтвердить данные») | +| `recognition_job` | Одна попытка распознать/распарсить файл. **Единственная точка обмена с NATS** (outbox/inbox, дедуп по `result_msg_id`, DLQ). Хранит сервис и версию, **одну** ссылку на нормализованный JSON-результат в S3 (v5.13), метрики, и **схему группировок шапки ОСВ по листам** (`group_schema jsonb`, v5.13 — вместо `osv_group_schema`). | → `stored_file`, `s3_object`; ← `osv_entry`, `financial_statement_meta` | ТЗ 6.2, 6.10; DECISION-4 | + +## Часть 05 — Разобранные ОСВ + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `osv_entry` | Строка ОСВ: документ (счёт + период), владелец, тип строки по схеме шапки (`group_type`, v5.13), уровень и родитель (`parent_entry_id` — **открыт вопрос, замечание 2**), код счёта и резолв в план счетов, сырое имя/ИНН контрагента и резолв в ЮЛ, шесть сумм, тип данных (БУ/валюта/кол-во), «подакцизный» для 90-го. Ручные правки: `is_modified`, `original_values`, `modified_by`. Признак ГСК/ГК/Внешний не хранится — выводится из периметра версии. | → `document`, `recognition_job`, `legal_entity` ×2, `chart_of_account` | ТЗ 6.2/6.5, Прил. 2; «Парсинг ОСВ» | + +## Часть 06 — Распознанные формы ББ/ОФР + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `financial_statement_meta` | Шапка распознанной формы (1:1 с `document` BALANCE/INCOME): ИНН и наименование как в документе, отчётная дата, единица измерения, реквизиты. Ревью формы (`reviewed_by/at`) и акцепт КА непройденных логических проверок с комментарием. Проверки (ТЗ 6.3) не хранятся — считаются на лету. Подтверждение документа — `document.confirmed_by`. | → `document`, `recognition_job`, `app_user`; ← `column`, `line` | ТЗ 6.2/6.3; FNR01; макет | +| `financial_statement_column` | Колонки-периоды формы с датой — нужны для межформенной проверки (1370 на начало года) и гудвилла. | → `meta`; ← `value` | ТЗ 6.3 п.7, 6.7 | +| `financial_statement_line` | Строка формы с кодом ОКУД и наименованием как распознано. Только строки с кодом; порядок и разделы — шаблон формы на фронте. | → `meta`; ← `value` | ТЗ 6.2, Прил. 2 | +| `financial_statement_value` | Число в ячейке (строка × колонка): сумма, уверенность, `is_recognized`, ручная правка с исходным значением. **v5.13:** подсветка «требует проверки» = `confidence < порог OR NOT is_recognized` — считается, флаг `needs_review` убран. | → `line`, `column`, `app_user` | ТЗ 6.2, Прил. 2 | + +## Часть 07 — Консолидация + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `consolidation` | Логическая консолидация: **ГК** + отчётный период. ГСК фиксируется/создаётся при верификации по фактическому периметру. Уникальна по (ГК, год, квартал). | → `company_group`, `related_group`; ← `consolidation_version` | ТЗ 6.10, 7.2 | +| `consolidation_version` | Версия консолидации: номер, статус, сценарий, подход, гудвилл, инициатор и его офис (маршрут), кто утвердил / верифицировал, решение УОБ, отклонения от Методики, версия правил элиминации, ссылка на предыдущую. После VERIFIED — read-only. | → `consolidation`, `app_user`, `regional_office`, self; ← всё в частях 07–08 | ТЗ 5.1, 6.4, 6.10; макет | +| `consolidation_status_history` | Каждый переход статуса: из → в, кто, когда, комментарий (обязателен при возврате). | → `consolidation_version`, `app_user` | ТЗ 6.10, 8.2 | +| `consolidation_lock` | «Взять в работу»: кто редактирует версию, перехват с причиной. Из макета — ANL-036. | → `consolidation_version`, `app_user` | макет | +| `consolidation_member` | Периметр версии — зафиксированный список ЮЛ; `is_head_entity` для гудвилла. | → `consolidation_version`, `legal_entity` | ТЗ 6.1, 6.10 | +| `consolidation_document` | **v5.13:** какие **документы** (не файлы) участвуют в версии. Чек-лист = `required_document_rule` × периметр − `consolidation_document`. Переиспользование одного документа в нескольких версиях/периодах, монозаёмщики из ЕХ. | → `consolidation_version`, `document` | ТЗ Прил. 2; ЕХ 7 | + +*Не хранятся (DECISION-5), считаются запросом по версии:* сводная ОСВ, признак контрагента ГСК/ГК/Внешний, пары ВГО, шахматка, результирующая таблица ВГО. + +## Часть 08 — Корректировки, гудвилл, результат КФО + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `correction` | Ручная корректировка ВГО: пара (правило + компания 1 + компания 2 — естественный ключ), ББ/ОФР, строки и суммы по Дт и Кт, дочерние строки для распределения, причина, обязательный комментарий, статус, номер версии (история — в аудите). КФО = свод − Σ корректировок. | → `consolidation_version`, `elimination_rule`, `legal_entity` ×2, `chart_of_account` ×2, self | ТЗ 6.4, 6.8, Прил. 9 | +| `goodwill` | Строка расчёта гудвилла: головная × дочерняя, доля владения, ФВ по 58.01, УК (1310) и добавочный капитал (1350 — вопрос методологам), гудвилл, «включить в КФО», комментарий. | → `consolidation_version`, `legal_entity` ×2, `app_user` | ТЗ 6.7, Прил. 3 п.2, Прил. 8 | +| `kfo_result` | Шапка результата КФО: когда посчитан, заморожен ли, ЧА и ВБ, выручка, прибыль, расхождения, пороги, обоснование, итог гудвилла. | → `consolidation_version` (1:1); ← `kfo_result_line` | ТЗ 6.4, 6.8, Прил. 10 | +| `kfo_result_line` | Строка консолидированного ББ/ОФР по коду ФСБУ 4/2023: сумма → авто-элиминация → корректировки → гудвилл → итог; «в т.ч. ВГО», источник, расхождение, комментарий. | → `kfo_result`; ← `kfo_result_line_company` | ТЗ 6.4, Прил. 10; макет | +| `kfo_result_line_company` | Разрез строки КФО по компаниям периметра: сумма и доля. | → `kfo_result_line`, `legal_entity` | ЕХ 6.6; макет | +| `client_kfo_line` | Сценарий «подтверждение КФО клиента»: по строке — значение и ВГО от клиента (ручной ввод), значение и ВГО банка, расхождение, причина. | → `consolidation_version`, `app_user` | ТЗ 5.1, 6.10; макет | + +## Часть 09 — Комментарии, аудит, Единое хранилище + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `comment` | Полиморфный комментарий к объекту (документу, ячейке шахматки по ключу пары, строке КФО). | → `app_user`; `object_type + object_id` без FK | ТЗ 6.3/6.4; макет | +| `audit_event` | Журнал действий (append-only): кто, когда, действие, раздел, объект, версия консолидации, before/after, результат, IP/браузер. Покрывает историю правок распознанных данных, корректировок, выгрузок, импорта справочников, входов, **изменений матрицы прав** (v5.13), подтверждения документов, **изменений ЮЛ и пользователей** (v5.14: `LEGAL_ENTITY_CHANGE`, `USER_CHANGE`). | → `app_user`, `consolidation_version` | ТЗ 8.2, Прил. 2; макет | +| `ex_snapshot` | Срез ЕХ: одна ГСК на одну отчётную дату, версия, статус (актуальный/архив), когда и кем верифицирован и записан. Самодостаточен — на консолидацию ссылается только данными. | → `related_group`, `company_group`, `app_user`, self; ← `member`, `item` | ЕХ ТЗ 6.2, 6.4, 5.3 | +| `ex_snapshot_member` | Периметр среза на дату: ЮЛ с ИНН и наименованием на момент верификации. | → `ex_snapshot`, `legal_entity` | ЕХ ТЗ 6.3 | +| `ex_snapshot_item` | Содержимое среза. Два вида: исходный документ → ссылка на **`document`** (v5.13; скачать — через его файл) и всё производное → `payload` jsonb. | → `ex_snapshot`, `document`, `legal_entity` | ЕХ ТЗ 5.2, 6.7 | + +--- + +## Как читать модель по сценарию «сформировать КФО» + +1. **Справочники** (01–03): ГК → ЮЛ → ГСК/ГСЗ; план счетов; правила элиминации; что обязательно загрузить. +2. **Создание консолидации** (07): `consolidation` от ГК + период → `consolidation_version` → `consolidation_member` (периметр). +3. **Загрузка** (04): `stored_file` + `s3_object` → `recognition_job` (queued → NATS → сервис → результат в S3 → done) → ядро создаёт **`document`** на каждый ББ / ОФР / ОСВ по счёту, найденный в файле. +4. **Разбор** (05–06): ОСВ → `osv_entry`; ББ/ОФР → `financial_statement_meta/column/line/value`. Аналитик проверяет, правит, подтверждает **документ**; логические проверки — на лету; сопоставляет контрагентов (→ `osv_entry.counterparty_id`, `legal_entity_alias`). Чек-лист закрывается по `document CONFIRMED`. +5. **Консолидация** (вычисляется): сводная ОСВ, пары ВГО, шахматка — из `osv_entry` × периметр × правила. Аналитик вносит `correction`, заполняет `goodwill`, при сценарии подтверждения — `client_kfo_line`. Результат — `kfo_result(_line)`. +6. **Согласование** (07): `consolidation_status_history` по маршруту; блокировка `consolidation_lock`. Что можно — решает `role_permission` + policy-слой по статусу/ЗО. +7. **Верификация → ЕХ** (09): `ex_snapshot` + `member` + `item` (документы по ссылке, остальное — payload). Версия → read-only. Всё это время — `audit_event`. + +## Открытые вопросы после v5.14 + +- **Замечание 2** (`osv_entry.parent_entry_id`, какие строки ОСВ хранить) — отложено; затрагивает и аналитику «Парсинг ОСВ» п.11. +- Замена по-документно (одна ОСВ из файла с пятью) — сейчас не предусмотрена, замена файлом целиком. Если понадобится — `superseded_by` переезжает в `document`. +- Статус `document.RECOGNIZED` (ручная проверка не требуется по порогу) — подтвердить с аналитиками, нужен ли, или все документы проходят через `NEEDS_CHECK → CONFIRMED`. diff --git a/openspec/changes/slice-2c-osv-ingest/.openspec.yaml b/openspec/changes/slice-2c-osv-ingest/.openspec.yaml new file mode 100644 index 0000000..9674885 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-10-05 +skip_specs: true diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md new file mode 100644 index 0000000..a357489 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -0,0 +1,25 @@ +# Design: Срез 2c (сквозное, docs) + +## Context +См. proposal и `arm-core/openspec/changes/slice-2c-osv-ingest/design.md` (D-1…D-13). + +## Decisions + +### D-1. v5.15 без expression-индекса +`lower(email)` как уникальный индекс не поддерживается сверкой ORM↔DBML (индексы сравниваются по колонкам). Вместо +этого email нормализуется в нижний регистр при записи, уникальность — прежний UNIQUE. Миграция приводит данные. + +### D-2. Правила маппинга фиксируются в §1.3/§9 после реализации +Как в Срезе 2a: задача docs-change «по факту» выполняется после merge дочернего change'а, расхождения — правкой §. + +### D-3. Автозамена — новый §9.5 +Решение владельца 05.10.2026: новый файл с документом того же типа/счёта/периода заменяет прежние (в т.ч. `CONFIRMED`, +с пометкой в аудите); удаление файла — пока нет `CONFIRMED` и файл никого не заменял; после Среза 4 — ограничение по консолидациям. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-05 | D-1: email в нижнем регистре вместо `lower(email)`-индекса | сверка DBML | индекс в v5.16 | +| 2 | 2026-10-05 | D-3: §9.5 автозамена/удаление по решению владельца; удаление заменившего файла запрещено | целостность цепочки версий | ослабить | +| 3 | 2026-10-05 | `period_check = UNKNOWN` до Среза 4 | нет версии консолидации | пересчёт в Срезе 4 | diff --git a/openspec/changes/slice-2c-osv-ingest/proposal.md b/openspec/changes/slice-2c-osv-ingest/proposal.md new file mode 100644 index 0000000..02c9a9c --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/proposal.md @@ -0,0 +1,37 @@ +# Proposal: Срез 2c (сквозное) — DBML v5.15, правила маппинга ОСВ в ядре по факту, OQ + +**Срез:** 2, подсрез 2c (загрузка файлов, очередь, маппинг результата парсера в `document`/`osv_entry`). **Репозиторий:** +arm-ka-docs. **Issue:** RSHB/arm-ka-docs#16. **Дочерний change:** `arm-core/openspec/changes/slice-2c-osv-ingest` (RSHB/arm-core#14). + +## Why + +Решения владельца 05.10.2026 (OQ-45 `NU` хранить; OQ-59 регистр email; OQ-01/02/05; автозамена файлов) требуют новой +версии модели и фиксации правил маппинга, которых в §1.3/§3/§9 нет в деталях (кто ставит статусы файла, как +разделены inbox и маппинг, что делать с повтором счёта в файле, что такое `period_check` без консолидации). + +## What Changes + +- **DBML v5.15** (`model/data-model-v5.15.dbml`, v5.14 остаётся): `osv_data_type + NU`; `app_user.email` в нижнем + регистре (структура без изменений); уточнения семантики `stored_file.status`/`recognition_job.group_schema` в changelog. + `entities-v5.15.md`, `04-data-model-guide.md`, ссылки в `CLAUDE.md`/`repo-templates` — на v5.15. +- **`05-domain-rules.md`**: §1.3 — маппинг в ядре по факту (TOTAL не хранится, нормализация кода, `account_root` при + нечисловом первом сегменте, повтор счёта в файле); §1.5 — `period_check = UNKNOWN` до Среза 4, `is_cumulative` по Q1; + §3 п. 4 — резолв без policy, нормализация имени ЮЛ как алиаса; §9.2 — таблица «кто ставит статус файла»; §9.3 — условия + `RECOGNIZED`/`NEEDS_CHECK`; новый §9.5 «Автозамена и удаление файлов» (решение владельца). +- **`03-architecture.md`**: цикл маппинга как третий фоновый процесс ядра (outbox, вахтёр, маппинг), OQ-69 закрыт. +- **`07-open-questions.md`**: закрыты OQ-67, OQ-69, OQ-70, OQ-59 (реализацией), OQ-109 (лимит на загрузке у ядра); + новые OQ: удаление файлов после появления консолидаций (ANL-020 — уточнить условие), ретеншн `expires_at` (связь с OQ-22). +- **`09-delivery-plan.md`**: 2c по факту; **`dev/qa-guide.md`**: сценарии загрузки ОСВ, статусы, просмотр строк через API. + +## Capabilities + +Change документный (`skip_specs: true`); поведение описывают спеки дочернего change'а. + +## Impact + +- Таблицы: `osv_entry` (enum), `app_user` (данные). Правила: §1.3, §1.5, §3, §9. Потребители: arm-core (миграция 0003, + `DBML_VERSION`), tabular-parser — без изменений (контракт не меняется). + +## Non-goals + +- UI (2d), ББ/ОФР (2b/Срез 3), replay заданий, JetStream, аудит обмена (OQ-55). diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md new file mode 100644 index 0000000..34ca88c --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -0,0 +1,10 @@ +# Tasks: Срез 2c (docs) + +- [x] 1.1 `model/data-model-v5.15.dbml` — changelog v5.15, `osv_data_type + NU`, note на `app_user.email`; проверка: 43 таблицы, 27 enum. +- [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` нет). +- [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`. diff --git a/repo-templates/arm-core.CLAUDE.md b/repo-templates/arm-core.CLAUDE.md index 84acf34..8d46ee1 100644 --- a/repo-templates/arm-core.CLAUDE.md +++ b/repo-templates/arm-core.CLAUDE.md @@ -16,7 +16,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.14.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. +- ORM 1:1 с `../docs/model/data-model-v5.15.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `backend/app/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.