КФО к старту спринта 4: §8, DBML v5.26, контракт API КФО (AURORA-1546/1547, #206) #207

Open
aep wants to merge 8 commits from feat/206-kfo-contract into main
30 changed files with 7321 additions and 1213 deletions

View File

@ -6,7 +6,7 @@ PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3)
# Пусто — проверяются все change'и и спеки; `make validate-openspec CHANGE=<id>` — один change # Пусто — проверяются все change'и и спеки; `make validate-openspec CHANGE=<id>` — один change
CHANGE ?= CHANGE ?=
# Текущая версия модели данных — по ней рисуются диаграммы (make diagrams) # Текущая версия модели данных — по ней рисуются диаграммы (make diagrams)
DBML ?= model/data-model-v5.24.dbml DBML ?= model/data-model-v5.26.dbml
.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-statements sync-statements statements-check check diagrams regress .PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-statements sync-statements statements-check check diagrams regress

View File

@ -1,7 +1,7 @@
# Элиминация ВГО: правила, расчёт, корректировки # Элиминация ВГО: правила, расчёт, корректировки
Документ для аналитика, 19.10.2026 (RSHB/arm-ka-docs#132; сопоставление — по решениям OQ-07 и OQ-173, #136). Основа — модель данных Документ для аналитика, 19.10.2026 (RSHB/arm-ka-docs#132; сопоставление — по решениям OQ-07 и OQ-173, #136). Основа — модель данных
[`model/data-model-v5.24.dbml`](../model/data-model-v5.24.dbml) и правила предметной области [`model/data-model-v5.26.dbml`](../model/data-model-v5.26.dbml) и правила предметной области
[`context/05-domain-rules.md`](../context/05-domain-rules.md) §3–§6, §8. Источник истины — они: при расхождении прав [`context/05-domain-rules.md`](../context/05-domain-rules.md) §3–§6, §8. Источник истины — они: при расхождении прав
DBML и `05-domain-rules.md`, а этот документ поправь следом. Где написано наше прочтение ТЗ, а не прямая цитата, это DBML и `05-domain-rules.md`, а этот документ поправь следом. Где написано наше прочтение ТЗ, а не прямая цитата, это
помечено; такие места собраны в раздел 6 вопросами. помечено; такие места собраны в раздел 6 вопросами.

View File

@ -76,7 +76,7 @@ git remote add origin <url-arm-core> && git push -u origin main
| Слой | Где | Статус | Правило для агента | | Слой | Где | Статус | Правило для агента |
|---|---|---|---| |---|---|---|---|
| Модель данных | `docs/model/data-model-v5.24.dbml` | стабильный, версионируется | единственный источник имён и структур | | Модель данных | `docs/model/data-model-v5.26.dbml` | стабильный, версионируется | единственный источник имён и структур |
| Правила домена | `docs/context/05-domain-rules.md` | курируемый — пишет человек/агент по итогам ревью | **единственный вход алгоритмов в код**; из ТЗ напрямую не кодируем | | Правила домена | `docs/context/05-domain-rules.md` | курируемый — пишет человек/агент по итогам ревью | **единственный вход алгоритмов в код**; из ТЗ напрямую не кодируем |
| Открытые вопросы | `docs/context/07-open-questions.md` | живой реестр | каждое допущение в коде — `ASSUMPTION(OQ-nn)` | | Открытые вопросы | `docs/context/07-open-questions.md` | живой реестр | каждое допущение в коде — `ASSUMPTION(OQ-nn)` |
| Экспорт ТЗ | `docs/source/tz/` | снимок на дату из `docs/source/tz/_export.md` | читать для контекста; расхождение с §05 → вопрос, не код | | Экспорт ТЗ | `docs/source/tz/` | снимок на дату из `docs/source/tz/_export.md` | читать для контекста; расхождение с §05 → вопрос, не код |

View File

@ -1,4 +1,4 @@
# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.24) # 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.26)
## Компоненты (= репозитории, см. `00-workspace-layout.md`) ## Компоненты (= репозитории, см. `00-workspace-layout.md`)
@ -251,7 +251,7 @@ api/src/
# машины статусов, policy — pydantic-модели на входе и выходе # машины статусов, policy — pydantic-модели на входе и выходе
services/ # use-cases: оркестрация domain + repositories + outbox + audit_event services/ # use-cases: оркестрация domain + repositories + outbox + audit_event
repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка) repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка)
models/ # ORM 1:1 с DBML v5.24; имена таблиц/колонок/enum — как в DBML models/ # ORM 1:1 с DBML v5.26; имена таблиц/колонок/enum — как в DBML
schemas/ # pydantic DTO API /api/v1 schemas/ # pydantic DTO API /api/v1
core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент), core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент),
# logger, exception_handlers # logger, exception_handlers
@ -341,7 +341,7 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
`version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`. `version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`.
- Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное - Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное
`<rev>_<slug>.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`. `<rev>_<slug>.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`.
- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.24. - PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.26.
### Инфраструктурные клиенты ### Инфраструктурные клиенты

View File

@ -1,6 +1,6 @@
# 04. Как читать модель данных v5.24 # 04. Как читать модель данных v5.26
Файлы: `docs/model/data-model-v5.24.dbml` (источник истины), `docs/model/entities-v5.24.md` (смысл таблиц; предыдущие Файлы: `docs/model/data-model-v5.26.dbml` (источник истины), `docs/model/entities-v5.26.md` (смысл таблиц; предыдущие
версии `data-model-v5.13…v5.20.dbml` и `entities-v5.13…v5.20.md` остаются как история), версии `data-model-v5.13…v5.20.dbml` и `entities-v5.13…v5.20.md` остаются как история),
`docs/model/diagrams/part01…09.{svg,png}` (диаграммы v5.21 по частям из таблицы ниже; перерисовываются из DBML командой `docs/model/diagrams/part01…09.{svg,png}` (диаграммы v5.21 по частям из таблицы ниже; перерисовываются из DBML командой
`make diagrams` в корне репозитория docs — скрипт `scripts/gen_model_diagrams.py`, нужен npx; при новой версии DBML `make diagrams` в корне репозитория docs — скрипт `scripts/gen_model_diagrams.py`, нужен npx; при новой версии DBML
@ -20,6 +20,8 @@
| 08 Корректировки и КФО | `correction`, `vgo_assignment`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line` | ручные решения и результат | | 08 Корректировки и КФО | `correction`, `vgo_assignment`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line` | ручные решения и результат |
| 09 Комментарии, аудит, ЕХ | `comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item` | полиморфные комментарии, append-only журнал, замороженные срезы | | 09 Комментарии, аудит, ЕХ | `comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item` | полиморфные комментарии, append-only журнал, замороженные срезы |
**v5.26:** расчёт КФО (§8 «КФО», AURORA-1546): `kfo_result_line_company` + `client_amount`, `vgo_amount` («по данным клиента», «в т. ч. взаимные» по компании); `kfo_result` + `warnings` и сводка корректировок; `audit_action.KFO_EDIT`; режим правила `min_or_full` (ББ 2). Номер v5.25 — PR #203 (факты качества первички), ещё не слит.
**v5.24:** новая таблица `vgo_assignment` — ручное решение аналитика по строке сводной ОСВ сверх автоматического разнесения по правилам ВГО (§5): привязка к правилу / снятие, «не учитывать в КФО» у нераспределённой строки (AURORA-1751). **v5.24:** новая таблица `vgo_assignment` — ручное решение аналитика по строке сводной ОСВ сверх автоматического разнесения по правилам ВГО (§5): привязка к правилу / снятие, «не учитывать в КФО» у нераспределённой строки (AURORA-1751).
**v5.23:** `consolidation_version.elimination_rules_version` — `not null`: версия правил элиминации, действующая на дату создания версии консолидации, фиксируется при создании; существующим версиям проставляется миграцией (AURORA-1536). **v5.23:** `consolidation_version.elimination_rules_version` — `not null`: версия правил элиминации, действующая на дату создания версии консолидации, фиксируется при создании; существующим версиям проставляется миграцией (AURORA-1536).

View File

@ -707,6 +707,8 @@ closing_* = closing_* строки Qn
deviation_amount = |ΣA − ΣB|; deviation_percent = deviation_amount / min(ΣA, ΣB) * 100 deviation_amount = |ΣA − ΣB|; deviation_percent = deviation_amount / min(ΣA, ΣB) * 100
mode = min: расхождение ≤ 5 % → элиминация_r = min(ΣA, ΣB); > 5 % → подсветка, элиминация_r = 0, ждёт корректировки mode = min: расхождение ≤ 5 % → элиминация_r = min(ΣA, ΣB); > 5 % → подсветка, элиминация_r = 0, ждёт корректировки
mode = manual: элиминация_r = 0 — только корректировка / гудвилл (§8) mode = manual: элиминация_r = 0 — только корректировка / гудвилл (§8)
mode = min_or_full (правило 2, DOC-274230; seed правится в AURORA-1546): обе стороны > 0 → min(ΣA, ΣB) без порога;
ΣB = 0 → ΣA; ΣA = 0 → 0; компании 2 — каскадом по строкам article_2_line (§8 «КФО», п. 4)
``` ```
**Знак** (решение владельца 23.10.2026): суммы сторон — **со знаком**, как в шахматке §6 (одно правило и одна пара дают **Знак** (решение владельца 23.10.2026): суммы сторон — **со знаком**, как в шахматке §6 (одно правило и одна пара дают
одинаковые ΣA и ΣB на обоих экранах). Отрицательное сальдо строки (§1: возможно) — встречная позиция, например аванс или одинаковые ΣA и ΣB на обоих экранах). Отрицательное сальдо строки (§1: возможно) — встречная позиция, например аванс или
@ -941,7 +943,14 @@ OQ-145); тем же правилом сервисы разрешают ничь
- **Контроль распределения** (`CorrectionBalanceOut`): `debit_total`/`credit_total` — суммы сохранённых статей; - **Контроль распределения** (`CorrectionBalanceOut`): `debit_total`/`credit_total` — суммы сохранённых статей;
`difference`/`difference_percent` между ними, `remainder_1`/`remainder_2` — сумма стороны агрегата минус сохранённая `difference`/`difference_percent` между ними, `remainder_1`/`remainder_2` — сумма стороны агрегата минус сохранённая
статья. Это только отображаемый контроль — корректировку можно сохранить, даже если стороны не сошлись; в КФО по статья. Это только отображаемый контроль — корректировку можно сохранить, даже если стороны не сошлись; в КФО по
выбранным статьям уходит меньшая сумма (ответ п. 8). выбранным статьям уходит меньшая сумма (ответ п. 8): при заполненных обеих сторонах каждая исключает меньший из
итогов, снимая его со своих статей по порядку (блок «КФО», п. 5). Применённая корректировка **заменяет** автосумму
агрегата, а не прибавляется к ней.
- **Автосумма агрегата** (`eliminated_amount`) — по режиму правила, **одной функцией домена с КФО** (блок «КФО», п. 4):
`min` — меньшая сторона в допуске, иначе 0; `manual` — 0; `min_or_full` (правило 2 после правки seed в AURORA-1546)
— меньшая сторона при обеих > 0, `amount_1` при пустой стороне 2, порога нет: `exceeds_threshold = false`, статус
`AUTO` при обеих сторонах, `ONE_SIDED` при пустой стороне 2 (`eliminated_amount = amount_1`). Сейчас
`services/correction_source.py` знает только `manual` и «остальное как `min`» — правится вместе с seed.
- **Порог отклонения агрегата** (`exceeds_threshold`, не путать с контролем выше): `deviation_percent = |ΣA − ΣB| / - **Порог отклонения агрегата** (`exceeds_threshold`, не путать с контролем выше): `deviation_percent = |ΣA − ΣB| /
min(ΣA, ΣB) × 100` сторон агрегата (§5, п. 1/2a); реализовано **строго больше 5 %** (R-35, `domain/corrections.py`, min(ΣA, ΣB) × 100` сторон агрегата (§5, п. 1/2a); реализовано **строго больше 5 %** (R-35, `domain/corrections.py`,
`arm-core#215`/Task 8) — ровно 5,00 % в допуске. ТЗ/DOC-272613 читались как «от 5 % включительно»; аналитик на это не `arm-core#215`/Task 8) — ровно 5,00 % в допуске. ТЗ/DOC-272613 читались как «от 5 % включительно»; аналитик на это не
@ -973,21 +982,199 @@ OQ-145); тем же правилом сервисы разрешают ничь
тоже); в КФО — `Σ G > 0` строк, где `use_goodwill` = действующая ∧ отмечена ∧ доля и комментарий заполнены ∧ `G > 0`, и тоже); в КФО — `Σ G > 0` строк, где `use_goodwill` = действующая ∧ отмечена ∧ доля и комментарий заполнены ∧ `G > 0`, и
только при `consolidation_version.goodwill_enabled` (переключатель на вкладке, OQ-27). Доли в итогах не складываются; только при `consolidation_version.goodwill_enabled` (переключатель на вкладке, OQ-27). Доли в итогах не складываются;
сумма заполненных долей разных ГО в одной ДО > 100 % — предупреждение, не ошибка. сумма заполненных долей разных ГО в одной ДО > 100 % — предупреждение, не ошибка.
- **КФО** (AURORA-1546, ещё не реализовано): для пары с `use_goodwill` — ГО `1170 − ФВ`, ДО `1310 − P`, ГО строка - **КФО** — блок «КФО» ниже, п. 8: ветка гудвилла идёт по паре этой таблицы независимо от агрегатов корректировок;
`GOODWILL` `+ G` (служебный код, ASSUMPTION OQ-37), обычный ББ-2 по этой паре не применяется; при `G ≤ 0` — обычный у пары с `use_goodwill` — ГО `1170` на ФВ, ДО `1310` на P, строка `GOODWILL` на G, агрегат правила 2 той же пары не
ББ-2, разница в 1370 не переносится. Изменение ввода, переключателя или источников делает результат КФО устаревшим. применяется; при `G ≤ 0` или выключенном `goodwill_enabled` — обычный агрегат правила 2. Изменение ввода,
переключателя или источников делает результат КФО устаревшим.
**КФО** (ТЗ 6.4, Прил. 10): по каждому коду строки ББ/ОФР: **КФО** (ТЗ 6.4, 6.8, Прил. 10; AURORA-1546 — расчёт, AURORA-1547 — экран, AURORA-1417 — пересчёт; ещё не реализовано;
`sum_amount = Σ по компаниям` (значения отчётной колонки ББ / ОФР за период, в тыс. руб.) → контракт — `dev/api-contract-kfo.md`). Источники, в порядке приоритета: DOC-274230 «Правила пар счетов» (Лысенко,
`eliminated_auto` (Σ сумм элиминации троек, §5 п. 1; пересчёт в тыс. руб.) → `eliminated_manual = Σ correction APPLIED` → `goodwill_adjust` → 27.10.2026), DOC-259025 «Гудвилл» (03.11), DOC-272613 «Корректировка данных агрегатом» (04.11); черновик команды
`consolidated_amount`; `vgo_amount = eliminated_auto + eliminated_manual`; разрез `kfo_result_line_company` (`amount`, DOC-274636 «Расчёт КФО» и DOC-274647 «Экран: КФО» — там, где страниц Лысенко нет (решение владельца 28.10.2026: страницы
`share_percent` = доля компании в консолидированном показателе). Эталон колонок — `Консолидация_Группа РБ.xlsx`, лист аналитика важнее черновика). Развилки, которые решает заказчик, закрыты вариантом черновика как стартовым и помечены
`01.10.2024`: «значение на дату | в т. ч. взаимные обязательства | очищенные показатели | доля в показателе по группе». OQ — менять по ответу.
**Пороги** (ТЗ 6.8, FNR03 шаги 4–5), `kfo_result.threshold_*`: **Единицы.** Результат КФО хранится и отдаётся в **тыс. руб.** с двумя знаками. Суммы ОСВ и агрегатов — в рублях;
- ББ: если `1300 < 0.1 × 1600` → Σ расхождений ≤ 1 % × 1600 (ВБ), иначе ≤ 10 % × 1300 (ЧА). в тысячи переводятся один раз — на ячейке «в т. ч. взаимные» (компания × строка, п. 6).
- ОФР: если `2200 / 2110 < 10 %` или отрицательна → ≤ 1 % × 2110 (выручка), иначе ≤ 10 % × 2200.
Превышение → `threshold_justification` обязателен (принятие риска), подсветка. 1. **Строки.** Бланк ББ и ОФР — полный вариант (`full`) той редакции, которую ядро выбирает по **отчётной дате версии**
(§11.9: ≤ 31.12.2024 — `66n`, позже — `fsbu4`; перечни — `contracts/statement-code-orders.yaml`, наименования —
словарь имён ядра). Плюс служебная строка `GOODWILL` (п. 8). Выводятся все строки бланка, в том числе нулевые.
Строки форм компаний вне бланка и строки без кода в КФО не входят (решение 17.10.2026; предупреждение
`LINE_OUTSIDE_BLANK`). Перечень дополнительных строк FNR03 («ППА», «Износ ОС», «Дополнительная информация») — OQ-180.
2. **«По данным клиента» (`client_amount`).** По каждой компании периметра (`consolidation_member`) — подтверждённый
(`CONFIRMED`) документ ББ / ОФР, привязанный к версии (`consolidation_document`). Колонка: ББ — на конец отчётного
квартала, ОФР — за период с начала года по конец квартала; по дате колонки, не по `is_current` (как УК гудвилла).
Строки со статусом `ok`; повторы кода суммируются. Единицы формы — словарём единиц `arm_statements` → тыс. руб.:
«тыс. руб.» — как есть, «руб.» — / 1000, «млн руб.» — × 1000, до двух знаков `ROUND_HALF_UP`.
- **Знак строк расходов.** Формы хранят расходы по-разному (§7, §11.9: скобки, минус или «без знака»). Строки
расходов бланка — **2120, 2210, 2220, 2330, 2350** — приводятся к одной конвенции: `client_amount = −|значение|`.
Остальные строки — как в форме (1370, 2300, 2400 и прочие бывают любого знака по существу). 2410 / 2411 (налог
на прибыль) в §7 стоят в `abs_terms`, но их знак не определён (OQ-12) — берутся как в форме.
- Нет формы → значения компании 0, `FORM_MISSING`; две и более подтверждённые формы одного типа → 0,
`FORM_AMBIGUOUS`; единицы не распознаны → 0, `FORM_UNITS_UNKNOWN`. Значение из ОСВ вместо формы — OQ-182.
- КФО считается и при неполном комплекте — по тому, что подтверждено и привязано, с `FORM_MISSING` (DOC-274636,
допущение 17; OQ-194).
3. **Агрегаты.** Взаимные суммы КФО берёт из **агрегатов таблицы корректировок** (§5 п. 2a, `VgoService`): тройка
(правило, компания 1, компания 2) с суммами сторон `amount_1`, `amount_2` (рубли) после ручных решений
`vgo_assignment`. Отдельного обхода ОСВ в КФО нет: что аналитик видит в таблице корректировок, то и вход КФО.
Нераспределённые строки ни в один агрегат не входят и в КФО не исключаются (`UNALLOCATED_ROWS` — число таких строк
без отметки «не учитывать в КФО»).
4. **Исключения агрегата.** Каждый агрегат даёт список исключений `(компания, код строки, сумма в рублях ≥ 0)`. Есть
применённая корректировка (`APPLIED`) — п. 5, иначе **автосумма** по режиму правила (`elimination_rule.elimination_mode`):
| Режим | Автосумма | Куда |
|---|---|---|
| `min` (ББ 3–8) | `min(amount_1, amount_2)`, если обе стороны > 0 и отклонение в допуске (порог — блок «Корректировка», OQ-178); иначе 0 | `article_1_line` компании 1, `article_2_line` компании 2 |
| `min_or_full` (ББ 2) | `amount_1 ≤ 0` → 0; иначе `amount_2 > 0` → `min(amount_1, amount_2)`, `amount_2 ≤ 0` (пусто или встречная позиция) → `amount_1`; порога нет (DOC-274230, правило 2) | `article_1_line` компании 1; компании 2 — каскадом по `article_2_line` |
| `manual` (ББ 1) | 0 — только корректировка | — |
| режимы ОФР | по правилам ОФР (DOC-274230, AURORA-1999, §5); до неё агрегатов ОФР нет — `INCOME_RULES_MISSING` | по правилу |
**Каскад** (составная статья «a/b/c»): сумма снимается со строки `a` компании в пределах её доступного остатка
(«по данным клиента» строки в рублях минус уже снятое с неё другими агрегатами, не ниже 0), остаток — со строки `b`,
затем `c`; не уместившееся — целиком на последнюю строку (DOC-274230: «если остаётся положительный остаток, он
исключается из строки 1370»). **Порядок:** сначала исключения гудвилл-пар (п. 8), затем агрегаты в порядке
`rule_no`, ИНН компании 1, ИНН компании 2; доступный остаток строки уменьшают все уже сделанные исключения —
гудвилл, автосуммы и корректировки.
Seed правил ББ версии 1 (`arm-core/api/seed/core/elimination_rules_v1.yaml`, правка — в AURORA-1546, на месте, без
новой версии правил: верифицированных версий нет): правило 2 — `elimination_mode: min_or_full`,
`article_2_line: "1310/1350/1370"`. **Правило 1 остаётся `manual`**: по DOC-274230 обе его суммы — у компании 2
(Кт 76.7.1 − Дт 76.7.9 с каскадом 1150 → 1450 → 1550), а сторона агрегата устроена «компания 1 — сторона 1,
компания 2 — сторона 2»; автосумма правила 1 — после переработки seed (OQ-193), до того — корректировкой.
Автосумма — **одна функция домена** для таблицы корректировок и КФО (блок «Корректировка», `eliminated_amount`).
5. **Корректировка заменяет автосумму.** Применённая корректировка агрегата отменяет его автосумму целиком и к ней не
прибавляется (DOC-274636, допущение 4; DOC-272613: корректировки не меняют суммы сторон). Исключения: компания 1 —
статьи `debit[]` (основная, затем дочерние), компания 2 — `credit[]`, суммы как введены. Если заполнены обе стороны
и их итоги разные, каждая сторона исключает **меньший** итог (ответ Лысенко п. 8): снимается со статей стороны по
порядку — основная, затем дочерние в порядке ввода — до исчерпания. Пустая сторона ничего не исключает, другая — как
введена (одностороннее правило 1, правила ОФР 1–3).
6. **«В т. ч. взаимные» по компании (`vgo_amount`).** Σ исключений компании по строке в рублях → тыс. руб., **до целых
тысяч, половина — от нуля** (`ROUND_HALF_UP` по модулю), со **знаком строки**: у строк расходов (п. 2, они ≤ 0)
`vgo_amount` отрицательный. На всех строках одно равенство `cleaned_amount = client_amount − vgo_amount`; исключение
приближает значение к нулю (при превышении — проходит через ноль). Округление ячейки, а не каждой тройки и не «без
округления» — OQ-184.
7. **Строка КФО.** По компании: `client_amount`, `vgo_amount`, `cleaned_amount`, `share_percent = cleaned / bank_amount
× 100` (`ROUND_HALF_UP`: хранится 4 знака, отдаётся 2; `bank_amount = 0` или `|доля| ≥ 1000 %` → `null`; бывает
отрицательной и больше 100 %). По строке: `sum_amount = Σ client`, `vgo_amount = Σ vgo по компаниям =
eliminated_auto + eliminated_manual` (manual — от агрегатов с корректировкой, auto — от агрегатов без корректировки и
гудвилл-пар; по одному агрегату они **не складываются**). Округляется одна ячейка: у компании по строке `vgo` — Σ
всех её исключений в рублях → тысячи (п. 6), manual — Σ исключений по корректировкам → тысячи тем же правилом,
**auto = vgo − manual**; так `vgo = auto + manual` и после округления. Дальше по строке —
`goodwill_adjust` (у `GOODWILL` и итогов, куда он входит), **`bank_amount = sum_amount − vgo_amount + goodwill_adjust`**
(`kfo_result_line.consolidated_amount`).
**Итоговые строки** (1100, 1200, 1300, 1400, 1500, 1600, 1700; 2100, 2200, 2300, 2400, 2500): «по данным клиента» —
итог из формы компании, а если строки итога в форме нет (упрощённая форма, `form_variant = simplified`) — Σ «по
данным клиента» его слагаемых; «в т. ч. взаимные» — Σ `vgo_amount` слагаемых **с коэффициентом +1** (знак уже в
значениях после приведения п. 2): 1100 ← 1110…1190; 1600 ← 1100 + 1200; 1700 ← 1300 + 1400 + 1500; 2100 ← 2110 +
2120; 2200 ← 2100 + 2210 + 2220; 2300 ← 2200 + 2310…2350; 2400 и 2500 — слагаемые по формулам §7 своей редакции
(66н / ФСБУ 4), но **без вычитания** (формулы §7 с минусом к КФО не применяются); строки «в т. ч.» (2421 и подобные)
— не слагаемые; `goodwill_adjust` — Σ слагаемых (то есть G у 1100 и 1600); очищенные — разность. Итог КФО — подписанный
клиентом итог минус взаимные, а не сумма своих строк (OQ-183). Доли компаний у 1100/1600 в сумме меньше 100 % на долю
гудвилла — у него нет разреза по компаниям. `deviation_amount` у итоговых строк — `null`.
8. **Гудвилл** (DOC-259025, блок «Гудвилл» выше). По паре ГО → ДО таблицы гудвилла с `use_goodwill` при
`goodwill_enabled` версии: исключения ГО `1170` на ФВ (все строки 58.01 и потомков пары, как в таблице гудвилла),
ДО `1310` на P (без каскада); G — в строку `GOODWILL`; агрегат правила 2 той же пары (компания 1 = ГО, компания 2 =
ДО), если он есть, **не применяется** — ни автосумма, ни корректировка (применённая корректировка такого агрегата
в сводку корректировок п. 11 не входит, предупреждение `CORRECTION_SUPERSEDED_BY_GOODWILL`). Строка 58.01 пары перенесена аналитиком в
другое правило или отмечена «не учитывать» — она всё равно в ФВ, предупреждение `GOODWILL_ROW_REASSIGNED` (ВГО по ней
может исключиться дважды, решает аналитик). Гудвилл включён, а применимых пар нет — `GOODWILL_NOT_APPLIED`.
Строка `GOODWILL` — раздел I, сразу после 1170, без разреза по компаниям; `goodwill_adjust = bank_amount = Σ G`
применимых пар, до целых тысяч; входит в 1100 и 1600; `kfo_result.goodwill_total` — то же значение. Место строки —
по DOC-259025 (отдельная строка раздела I); макет `[31-04]` показывает «В т.ч. Гудвилл» под 1170 — OQ-185.
9. **Расхождения по статьям.** По всем агрегатам режима `min` (ББ и ОФР), **в том числе односторонним** (тогда
расхождение — вся непустая сторона) и с отрицательной стороной: `|amount_1 − amount_2|` — на статью стороны с
большей суммой (компания 1 — `article_1_line`, компания 2 — `article_2_line`), независимо от корректировок (OQ-186).
Агрегаты `manual`, `min_or_full` и гудвилл-пары в расхождения не входят. По строке — Σ в рублях → тыс. руб. до целых
(`deviation_amount`). `total_deviation_bb` / `total_deviation_ofr` — Σ по строкам бланка своего отчёта, без итогов;
`has_deviations` — хотя бы одна строка ≠ 0.
10. **Контрольные пороги** (ТЗ 6.8, Методика пп. 5.1.2.4, 5.4; величины — КФО банка, тыс. руб.):
- ББ: ЧА = строка 1300 (как FNR03, OQ-187), ВБ = 1600. `ЧА ≥ 10 % ВБ` → база ЧА, предел 10 % ЧА; иначе (в том
числе ЧА ≤ 0) → база ВБ, предел 1 % ВБ.
- ОФР: `2200 ≥ 0` и `2200 / 2110 ≥ 10 %` → база 2200, предел 10 % 2200; иначе → база 2110, предел 1 % 2110.
`2110 = 0` → база 2110, предел 0 (OQ-188).
- Превышение — `total_deviation > предел`; равно — норма. Процент от базы — для сводки, `ROUND_HALF_UP` до двух
знаков, база = 0 → `null`. Предел — `ROUND_HALF_UP` до двух знаков.
- Превышение хотя бы по одному отчёту → нужно обоснование принятия риска (`threshold_justification`). Обязательность
перед отправкой на верификацию (OQ-189, вариант черновика 2) проверяет переход статуса — AURORA-2002 / 1414.
11. **Реквизиты формы** (ТЗ 6.4, DOC-274636 правило 9): ГК, ГСК, отчётная дата, номер и статус версии, сценарий,
подход (`approach` — пакет документов), гудвилл учтён да/нет и сумма, расхождения ББ/ОФР и превышения, обоснование,
корректировки — число агрегатов с применённой корректировкой и Σ их исключений по отчёту, обеих сторон
(`kfo_result.corrections_count_bb/_ofr`; `corrections_amount_bb/_ofr` = Σ `eliminated_manual` строк бланка без
итогов, тыс. руб.), причины отклонений от Методики
(`consolidation_version.methodology_deviations` — ввод на вкладке «КФО»), решение УОБ (номер и дата — только
чтение). «Подход по Методике» и «согласовано клиентом» — не делаем до ответа заказчика (OQ-190, OQ-191).
12. **Пересчёт и заморозка.** Результат хранится у версии (`kfo_result`, одна запись). Пока версия не заморожена,
чтение отдаёт результат по текущим данным: если входы изменились после `computed_at`, сервер пересчитывает и
сохраняет до ответа (как обнаруживать изменения — AURORA-1417). Кнопки «Рассчитать» нет (OQ-192). Комментарии строк
и обоснование переносятся при пересчёте по (тип отчёта, код строки). При «Верифицирован» `is_frozen = true` (ставит
AURORA-2002) — дальше результат только читается. **Предупреждения** `{code, message, legal_entity_id?,
statement_type?, count?}` не блокируют расчёт; они и сводка корректировок — часть результата и **хранятся в
`kfo_result`** (`warnings`), чтобы замороженная версия отдавала то, что видели при верификации. Коды:
`FORM_MISSING`, `FORM_AMBIGUOUS`, `FORM_UNITS_UNKNOWN`, `LINE_OUTSIDE_BLANK`, `UNALLOCATED_ROWS`,
`INCOME_RULES_MISSING`, `GOODWILL_NOT_APPLIED`, `GOODWILL_ROW_REASSIGNED`, `CORRECTION_SUPERSEDED_BY_GOODWILL`.
Правка ручных полей — аудит `KFO_EDIT`.
**Пример** (тыс. руб., если не сказано иное; те же числа — в `examples` контракта). Три компании: А «Альфа», Б «Бета»,
В «Гамма»; отчётная дата 01.10.2025 (ФСБУ 4). Агрегаты, рубли:
| Агрегат | `amount_1` | `amount_2` | Что исключается |
|---|---|---|---|
| пр. 6, А → Б | 102 000 000,00 | 100 000 400,50 | отклонение 2,00 % (1,9996) — автосумма 100 000 400,50: А 1230, Б 1520 |
| пр. 6, Б → А | 10 000 000,00 | 50 000 000,00 | 400 % — автосумма 0; корректировка Дт Б 1230 10 000 000, Кт А 1520 10 000 000 |
| пр. 6, А → В | 30 000 000,00 | 29 100 000,00 | 3,09 % (автосумма была бы 29 100 000), но корректировка: Дт А 1230 20 000 000 + А 1170 5 000 000, Кт В 1520 22 500 000 — итоги 25 000 000 и 22 500 000, каждая сторона исключает 22 500 000: А 1230 20 000 000, А 1170 2 500 000, В 1520 22 500 000 |
| пр. 2, А → Б | 50 000 000,00 | 40 000 000,00 | гудвилл-пара (ФВ 50 000 000, УК 40 000 000, d = 80 %, P = 32 000 000, G = 18 000 000): А 1170 50 000 000, Б 1310 32 000 000, `GOODWILL` 18 000; агрегат не применяется |
| пр. 2, А → В | 15 000 000,00 | 0,00 | `min_or_full`, стороны 2 нет → 15 000 000: А 1170; у В каскад 1310 (есть 10 000 000) → 10 000 000, 1350 (3 000 000) → 3 000 000, остаток 2 000 000 → 1370 |
| ОФР, А → Б | 10 000 000,00 | 10 000 000,00 | по правилам ОФР 1–2 (S без НДС): А 2110, Б 2120 |
ББ (по компании: по данным клиента / в т. ч. взаимные / очищенные):
| Строка | А | Б | В | КФО банка | Расхождения |
|---|---|---|---|---|---|
| 1170 | 70 000 / 67 500 / 2 500 | 0 / 0 / 0 | 0 / 0 / 0 | 2 500 | 0 |
| `GOODWILL` | — | — | — | 18 000 | 0 |
| 1100 | 70 000 / 67 500 / 2 500 | 0 / 0 / 0 | 0 / 0 / 0 | 20 500 (2 500 + гудвилл 18 000) | — |
| 1230 | 520 000 / 120 000 / 400 000 (70,42 %) | 130 000 / 10 000 / 120 000 (21,13 %) | 48 000 / 0 / 48 000 (8,45 %) | 568 000 | 2 900 |
| 1600 | 590 000 / 187 500 / 402 500 | 130 000 / 10 000 / 120 000 | 48 000 / 0 / 48 000 | 588 500 | — |
| 1310 | 100 000 / 0 / 100 000 | 40 000 / 32 000 / 8 000 | 10 000 / 10 000 / 0 | 108 000 | 0 |
| 1350 | 0 / 0 / 0 | 0 / 0 / 0 | 3 000 / 3 000 / 0 | 0 | 0 |
| 1370 | 280 000 / 0 / 280 000 | −20 000 / 0 / −20 000 | 5 000 / 2 000 / 3 000 | 263 000 | 0 |
| 1300 | 380 000 / 0 / 380 000 | 20 000 / 32 000 / −12 000 | 18 000 / 15 000 / 3 000 | 371 000 | — |
| 1410 | 50 000 / 0 / 50 000 | 0 / 0 / 0 | 0 / 0 / 0 | 50 000 | 0 |
| 1520 | 160 000 / 10 000 / 150 000 | 110 000 / 100 000 / 10 000 | 30 000 / 22 500 / 7 500 | 167 500 | 40 000 |
| 1700 | 590 000 / 10 000 / 580 000 | 130 000 / 132 000 / −2 000 | 48 000 / 37 500 / 10 500 | 588 500 | — |
- 1230 А: 100 000 400,50 + 20 000 000 = 120 000 400,50 руб. → 120 000 тыс. (округление ячейки); Б 1520 —
100 000 400,50 → 100 000. Корректировка Б → А заменила автосумму (0), а не прибавилась к ней; корректировка А → В —
тоже, хотя автосумма была бы 29 100 000.
- Расхождения: 1 999 599,50 (А → Б) + 900 000 (А → В) = 2 899 599,50 руб. → 2 900 на 1230 А (сторона 1 больше);
40 000 000 → 40 000 на 1520 А (сторона 2 Б → А больше). Пары правила 2 в расхождения не входят. Всего ББ 42 900.
- Порог ББ: ЧА 371 000 ≥ 10 % ВБ (58 850) → база ЧА, предел 37 100; 42 900 > 37 100 — **превышение** (11,56 % от ЧА),
нужно обоснование.
- Сходимость: 1600 = 1700 = 588 500 (у ГО −ФВ, у ДО −P, гудвилл +G).
- **Гудвилл выключен** (та же версия, `goodwill_enabled = false`): агрегат пр. 2 А → Б — `min_or_full` → 40 000 000;
А 1170 −40 000 (всего 57 500), Б 1310 −40 000 (каскад уместился в 1310); `GOODWILL` 0; 1600 = 1700 = 580 500;
ЧА 363 000, предел 36 300, превышение 11,82 %.
ОФР (у Б 2120 в форме «без знака» +60 000 → приведено к −60 000; у В формы ОФР нет — `FORM_MISSING`, нули):
| Строка | А | Б | В | КФО банка |
|---|---|---|---|---|
| 2110 | 200 000 / 10 000 / 190 000 | 80 000 / 0 / 80 000 | 0 / 0 / 0 | 270 000 |
| 2120 | −120 000 / 0 / −120 000 | −60 000 / −10 000 / −50 000 | 0 / 0 / 0 | −170 000 |
| 2100 | 80 000 / 10 000 / 70 000 | 20 000 / −10 000 / 30 000 | 0 / 0 / 0 | 100 000 |
| 2200 | 50 000 / 10 000 / 40 000 | 15 000 / −10 000 / 25 000 | 0 / 0 / 0 | 65 000 |
Σ `vgo_amount` строки 2100 = 10 000 − 10 000 = 0 — валовая прибыль группы не меняется. Порог ОФР: 65 000 / 270 000 =
24 % ≥ 10 % → база 2200, предел 6 500; расхождений ОФР нет (агрегаты ОФР 1–2 — не `min`).
**Прогон на «Русском бисквите»** (критерий AURORA-1546): версия на 01.10.2024 — бланк 66н (есть 1120, 1231, 2930;
нет 1105, 1215, 2420), против листа `01.10.2024` эталона `Консолидация_Группа РБ.xlsx` (колонки «значение на дату |
в т. ч. взаимные обязательства | очищенные показатели | доля в показателе по группе»). Ожидаемые расхождения с эталоном:
алгоритм по ТЗ (min, порог 5 %), а эталон исключает каждую сторону полностью (OQ-155); ручные правки аналитика —
показываются, не прячутся.
**Сравнение с КФО клиента** (`scenario = CONFIRM`): `client_kfo_line` построчно: `client_amount`, `client_vgo_amount` **Сравнение с КФО клиента** (`scenario = CONFIRM`): `client_kfo_line` построчно: `client_amount`, `client_vgo_amount`
(ручной ввод), `bank_amount`, `bank_vgo_amount` (из `kfo_result_line`), `deviation_percent` к наименьшему, причина. (ручной ввод), `bank_amount`, `bank_vgo_amount` (из `kfo_result_line`), `deviation_percent` к наименьшему, причина.

View File

@ -183,3 +183,18 @@
| OQ-175 | Процент расхождения шахматки при отрицательной меньшей стороне: `variance_pct = variance / min(amount_a, amount_b) * 100` при отрицательном сальдо (возможно по §1) уходит в минус — пара (100, −5) даёт −2100 %, порог > 5 % не срабатывает, пара не подсвечивается. Считать от `\|min\|`, от меньшей по модулю стороны или подсвечивать пары с разными знаками всегда? | ревью arm-core #165 (AURORA-1651), 22.10.2026 | §6, шахматка, xlsx | открыт; до решения — как в коде (min со знаком); у пар ВГО §5 суммы сторон тоже со знаком (решение владельца 23.10.2026), но при `min ≤ 0` процент не считается и тройка подсвечивается | | OQ-175 | Процент расхождения шахматки при отрицательной меньшей стороне: `variance_pct = variance / min(amount_a, amount_b) * 100` при отрицательном сальдо (возможно по §1) уходит в минус — пара (100, −5) даёт −2100 %, порог > 5 % не срабатывает, пара не подсвечивается. Считать от `\|min\|`, от меньшей по модулю стороны или подсвечивать пары с разными знаками всегда? | ревью arm-core #165 (AURORA-1651), 22.10.2026 | §6, шахматка, xlsx | открыт; до решения — как в коде (min со знаком); у пар ВГО §5 суммы сторон тоже со знаком (решение владельца 23.10.2026), но при `min ≤ 0` процент не считается и тройка подсвечивается |
| OQ-176 | Счёт `000` «Вспомогательный» (технический счёт 1С для ввода остатков, `kind = AP`, `is_off_balance = false`) входит в шахматку «все счета». Исключать его вместе с забалансовыми? | ревью arm-core #165 (AURORA-1651), 22.10.2026 | §6, режим «все счета» | открыт; до решения — входит (решение владельца 22.10.2026: не трогать) | | OQ-176 | Счёт `000` «Вспомогательный» (технический счёт 1С для ввода остатков, `kind = AP`, `is_off_balance = false`) входит в шахматку «все счета». Исключать его вместе с забалансовыми? | ревью arm-core #165 (AURORA-1651), 22.10.2026 | §6, режим «все счета» | открыт; до решения — входит (решение владельца 22.10.2026: не трогать) |
| OQ-178 | Порог превышения отклонения агрегата в таблице корректировок — ровно 5 % в допуске (строго `>`) или на проверку («от 5 % включительно», как читается DOC-272613/ТЗ)? Аналитик (Лысенко) на прямой вопрос не отвечал | `dev/api-contract-corrections.md` §2; R-34/R-35 (`arm-core`, `openspec/changes/corrections-table-ui/design.md`) | §5 п. 2a, §8 «Корректировка», экран «Таблица корректировок» | открыт; реализовано строго `>` 5 % (R-35, `domain/corrections.py`) — фронт и сервер синхронны между собой, но не точно со спорным текстом ТЗ; правка при подтверждении — один оператор в `editorModel.ts`/`CorrectionTable.tsx` и в `domain/corrections.py` | | OQ-178 | Порог превышения отклонения агрегата в таблице корректировок — ровно 5 % в допуске (строго `>`) или на проверку («от 5 % включительно», как читается DOC-272613/ТЗ)? Аналитик (Лысенко) на прямой вопрос не отвечал | `dev/api-contract-corrections.md` §2; R-34/R-35 (`arm-core`, `openspec/changes/corrections-table-ui/design.md`) | §5 п. 2a, §8 «Корректировка», экран «Таблица корректировок» | открыт; реализовано строго `>` 5 % (R-35, `domain/corrections.py`) — фронт и сервер синхронны между собой, но не точно со спорным текстом ТЗ; правка при подтверждении — один оператор в `editorModel.ts`/`CorrectionTable.tsx` и в `domain/corrections.py` |
| OQ-180 | Строки КФО вне бланка ФСБУ: нужны ли строки FNR03 «ППА», «Право пользования активами», «Износ основных средств», «Расхождение баланса по Активу и Пассиву», «Форма 0710002 с.2», «Дополнительная информация»? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 1; FNR03 (DOC-221276) | §8 «КФО» п. 1 | открыт, вопрос заказчику; допущение принято (docs #206): только бланк редакции на отчётную дату + строка `GOODWILL` |
| OQ-181 | Состав колонок формы КФО: колонки макета `[31-04]` плюс расчётные «Расхождения» или ещё «КФО клиента» и ручные «Расхождения/Примечания», как в FNR03 и Прил. 10? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 2; макет `[31-04]`; ТЗ Прил. 10 | §8 «КФО» п. 7; `dev/api-contract-kfo.md` | открыт, вопрос заказчику; допущение принято (docs #206): колонки макета + «Расхождения» (расчётные); «Примечания» = «Комментарий»; КФО клиента — только на «Подтверждении КФО клиента» |
| OQ-182 | Значение компании без подтверждённой формы ББ/ОФР: 0 с предупреждением, строки из ОСВ по счетам статьи (нужна таблица «строка ↔ счета») или КФО не считается? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 3; ТЗ Прил. 10 («Баланс (форма 01)» / «ОСВ»); `kfo_result_line.source_kind` | §8 «КФО» п. 2 | открыт, вопрос заказчику; допущение принято (docs #206): 0 и `FORM_MISSING`; `source_kind` всегда `balance` |
| OQ-183 | Итоговая строка КФО (1100…1700, 2100…2500): по формуле бланка из очищенных строк или как итог формы клиента минус взаимные слагаемых? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 7 | §8 «КФО» п. 7 | открыт, вопрос заказчику; допущение принято (docs #206): итог формы клиента минус Σ взаимных слагаемых (совпадает с подписанным клиентом) |
| OQ-184 | Округление рублей до тысяч: ячейка «в т. ч. взаимные» (компания × строка) после сложения, каждая тройка до сложения, без округления (тысячи с копейками) или банковское? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 8 | §8 «КФО» п. 6 | открыт, вопрос заказчику; допущение принято (docs #206): ячейка после сложения в рублях, до целых тысяч, половина от нуля |
| OQ-185 | Место гудвилла в КФО: отдельная строка раздела I (DOC-259025, FNR03 «Заполнение поля Гудвил»), «В т.ч. Гудвилл» под 1170 (макет `[31-04]`) или строка 1105 бланка ФСБУ 4 (см. OQ-37)? | DOC-259025; DOC-274636, вопрос 9; макет `[31-04]`; ФСБУ 4/2023 | §8 «КФО» п. 8 | открыт, вопрос заказчику; допущение принято (docs #206): служебная строка `GOODWILL` раздела I после 1170, входит в 1100 и 1600, без разреза по компаниям; решают методологи |
| OQ-186 | «Совокупный объём расхождений» для порога: разницы сторон всех пар как найдены, только пары без корректировки или остатки после корректировок? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 10; FNR03 шаг 4 | §8 «КФО» п. 9 | открыт, вопрос заказчику; допущение принято (docs #206): разницы сторон всех агрегатов `min` как найдены, независимо от корректировок |
| OQ-187 | ЧА для порога ББ: строка 1300 КФО банка (FNR03) или расчёт по приказу Минфина № 84н (≈ 1300 + 1530)? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 11; ТЗ 6.8; Методика 5.1.2.4 | §8 «КФО» п. 10 | открыт, вопрос заказчику; допущение принято (docs #206): строка 1300 |
| OQ-188 | Порог при нулевой базе (2110 = 0 или 1600 = 0): предел 0 (любое расхождение — превышение) или порог не применяется? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», допущение 12 | §8 «КФО» п. 10 | открыт, вопрос заказчику; допущение принято (docs #206): предел 0 |
| OQ-189 | Что делает превышение порога: только подсветка, обязательное обоснование до отправки на верификацию, только до финала или блокировка верификации? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 13; DOC-037736 («НЕ ТОЧНО»); Excel В-01 | §8 «КФО» п. 10; AURORA-2002/1414 | открыт, вопрос заказчику; допущение принято (docs #206): обоснование обязательно до отправки на «Верификацию РФ/ГО» и до финала; проверяет переход статуса |
| OQ-190 | «Подход» в реквизитах КФО: пакет документов (`approach` BASIC/EXTENDED, ТЗ 5.1) или подход по Методике (сноска 15: «по Методике / индивидуальный / принята аудированная отчётность»)? Для второго нужно новое поле версии | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 14 | §8 «КФО» п. 11 | открыт, вопрос заказчику; допущение принято (docs #206): показывается только `approach`; поле «подход по Методике» не заводим до ответа |
| OQ-191 | Где фиксируется согласование КФО клиентом (Методика 5.8, 6.1): поле версии «да / нет / не направлялось» с датой или не вести в АРМ? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 15 | §8 «КФО» п. 11 | открыт, вопрос заказчику; допущение принято (docs #206): не ведём до ответа (вариант черновика — поле версии) |
| OQ-192 | Пересчёт КФО: автоматически после каждого изменения (Excel Р-11, Р-23), по кнопке «Рассчитать КФО» с пометкой «устарела» (FNR03) или только при открытии? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», вопрос 16; AURORA-1417 | §8 «КФО» п. 12 | открыт, вопрос заказчику; допущение принято (docs #206): автоматически: чтение пересчитывает устаревший результат, кнопки нет |
| OQ-193 | Правило ББ 1 (76.7.1 / 76.7.9) автоматически по DOC-274230: обе суммы — у компании 2 (Кт 76.7.1 − Дт 76.7.9, каскад 1150 → 1450 → 1550), а сторона агрегата §5 устроена «компания 1 — сторона 1». Как переложить правило в seed (стороны, режим)? | DOC-274230 правило 1; `arm-core/api/seed/core/elimination_rules_v1.yaml` | §5 п. 1, §8 «КФО» п. 4 | открыт, задача команды (follow-up AURORA-1546); до решения правило 1 — `manual`, только корректировкой |
| OQ-194 | КФО при неполном комплекте документов (обязательный комплект и проверки перед расчётом отложены, Excel В-01, В-08): считать по тому, что подтверждено и привязано, с предупреждением, или не считать, пока комплект не полон? | DOC-274636 «Расчёт КФО» (черновик), «Что решить», допущение 17 | §8 «КФО» п. 2 | открыт, вопрос заказчику; допущение принято (docs #206): считать, у компании без формы — нули и `FORM_MISSING` |

View File

@ -48,8 +48,8 @@
| Файл | Что | | Файл | Что |
|---|---| |---|---|
| `data-model-v5.24.dbml` | источник истины; changelog v5.1…v5.24 в шапке; блоки «ИСТОЧНИК» над каждой таблицей (v5.13…v5.23 остаются рядом как история) | | `data-model-v5.26.dbml` | источник истины; changelog v5.1…v5.26 в шапке (v5.25 — PR #203); блоки «ИСТОЧНИК» над каждой таблицей (v5.13…v5.24 остаются рядом как история) |
| `entities-v5.24.md` | описание сущностей по частям 01–09, таблицы «Что изменилось» по версиям, сценарий «как читать модель», открытые вопросы (v5.13…v5.23 остаются как история) | | `entities-v5.26.md` | описание сущностей по частям 01–09, таблицы «Что изменилось» по версиям, сценарий «как читать модель», открытые вопросы (v5.13…v5.24 остаются как история) |
| `diagrams/part01…09.{svg,png}` | диаграммы v5.21 по частям; перерисовываются `make diagrams` (`scripts/gen_model_diagrams.py`) | | `diagrams/part01…09.{svg,png}` | диаграммы v5.21 по частям; перерисовываются `make diagrams` (`scripts/gen_model_diagrams.py`) |
| `source/tz/data-model-part1_4.dbml` (лежит не в `model/`, а в экспорте ТЗ) | v4 часть 1 — историческое | | `source/tz/data-model-part1_4.dbml` (лежит не в `model/`, а в экспорте ТЗ) | v4 часть 1 — историческое |

View File

@ -9,8 +9,8 @@
1. `CLAUDE.md` (workspace) и `docs/context/00-workspace-layout.md` — как устроена рабочая область, статус аналитики, макет. 1. `CLAUDE.md` (workspace) и `docs/context/00-workspace-layout.md` — как устроена рабочая область, статус аналитики, макет.
2. `docs/context/01-project-overview.md`, `02-glossary.md` — предметная область. 2. `docs/context/01-project-overview.md`, `02-glossary.md` — предметная область.
3. `docs/context/03-architecture.md`, `04-data-model-guide.md`, затем **весь** `docs/model/data-model-v5.24.dbml` и 3. `docs/context/03-architecture.md`, `04-data-model-guide.md`, затем **весь** `docs/model/data-model-v5.26.dbml` и
`docs/model/entities-v5.24.md`. `docs/model/entities-v5.26.md`.
4. `docs/context/05-domain-rules.md` — алгоритмы (особенно §1.0 и §12 — парсеры ищут структуру по содержимому); 4. `docs/context/05-domain-rules.md` — алгоритмы (особенно §1.0 и §12 — парсеры ищут структуру по содержимому);
`06-test-data.md`; `07-open-questions.md`; `10-user-journey.md`; `09-delivery-plan.md`. `06-test-data.md`; `07-open-questions.md`; `10-user-journey.md`; `09-delivery-plan.md`.
5. `arm-core/`, `tabular-parser/`, `scan-gateway/` — три репозитория, развёрнутые из нулевого коммита шаблона команды, 5. `arm-core/`, `tabular-parser/`, `scan-gateway/` — три репозитория, развёрнутые из нулевого коммита шаблона команды,
@ -37,7 +37,7 @@ NATS, MinIO, эмулятор ИС Сканирование), контракт `
`07-open-questions.md`; если нужен ответ для среза — делай допущение по правилам из этого файла. `07-open-questions.md`; если нужен ответ для среза — делай допущение по правилам из этого файла.
Ограничения на всё время работы: Ограничения на всё время работы:
- имена таблиц, колонок и enum'ов — ровно как в DBML v5.24; поля, которых нет в DBML, не изобретать; - имена таблиц, колонок и enum'ов — ровно как в DBML v5.26; поля, которых нет в DBML, не изобретать;
- алгоритмы — только из `05-domain-rules.md`; из страниц ТЗ напрямую не кодировать; - алгоритмы — только из `05-domain-rules.md`; из страниц ТЗ напрямую не кодировать;
- парсеры: никаких координат ячеек, букв колонок, имён листов и номеров строк в коде — структура по содержимому, - парсеры: никаких координат ячеек, букв колонок, имён листов и номеров строк в коде — структура по содержимому,
синонимы в `rules/*.yaml`; фикстуры — регрессия, не спецификация; синонимы в `rules/*.yaml`; фикстуры — регрессия, не спецификация;

View File

@ -174,9 +174,9 @@ side_1 AggregateSideOut дебетовая сторона (
side_2 AggregateSideOut кредитовая сторона (компания 2) side_2 AggregateSideOut кредитовая сторона (компания 2)
deviation_amount money deviation_amount money
deviation_percent percent | null к меньшей стороне; null — меньшая сторона ≤ 0 deviation_percent percent | null к меньшей стороне; null — меньшая сторона ≤ 0
exceeds_threshold bool deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой exceeds_threshold bool deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой; у min_or_full — всегда false
status "AUTO" | "DISCREPANCY" | "ONE_SIDED" | "MANUAL" как EliminationTotalItemOut status "AUTO" | "DISCREPANCY" | "ONE_SIDED" | "MANUAL" как EliminationTotalItemOut
eliminated_amount money автоматическая сумма элиминации агрегата (§5), справочно eliminated_amount money автосумма агрегата по режиму правила (§8 «КФО» п. 4) — её исключает КФО, если нет корректировки
composition_changed bool состав или суммы изменились после применённой корректировки (ответ 5; ответ п. 10 — подсвечивать) composition_changed bool состав или суммы изменились после применённой корректировки (ответ 5; ответ п. 10 — подсвечивать)
correction CorrectionOut | null действующая корректировка (APPLIED; DRAFT — зарезервирован) correction CorrectionOut | null действующая корректировка (APPLIED; DRAFT — зарезервирован)
``` ```
@ -257,7 +257,12 @@ rule_id? (uuid, для ASSIGN)}`.
агрегаты и нераспределённые строки (с `line_code` и `candidate_rule_ids`). Нынешняя упаковка считает каждое правило агрегаты и нераспределённые строки (с `line_code` и `candidate_rule_ids`). Нынешняя упаковка считает каждое правило
отдельно (TODO в `intercompany_matrix.build_pairs`) — для таблицы корректировок строка должна попадать не более чем в одно отдельно (TODO в `intercompany_matrix.build_pairs`) — для таблицы корректировок строка должна попадать не более чем в одно
правило. правило.
- Правила ББ 1 и 2 (DOC-274230) имеют свой расчёт и не наследуют min; что идёт в `eliminated_amount` по ним — по DOC-274230. - Правила ББ 1 и 2 (DOC-274230) имеют свой расчёт и не наследуют min. **Правило 2** — режим `min_or_full` (DBML v5.26,
`05-domain-rules.md` §8 «КФО» п. 4; seed и `services/correction_source.py` правятся в AURORA-1546): обе стороны > 0 →
`eliminated_amount = min`, `status = AUTO`; сторона 2 пуста → `eliminated_amount = amount_1`, `status = ONE_SIDED`;
сторона 1 пуста → 0, `ONE_SIDED`; `exceeds_threshold = false` всегда (порога 5 % у правила нет). **Правило 1** остаётся
`manual` (`status = MANUAL`, 0) до переработки seed (OQ-193). Автосумма — **та же функция, по которой считает КФО**:
что аналитик видит в `eliminated_amount`, то и исключает КФО, если корректировки нет (корректировка её заменяет).
- ОФР: тот же ответ с `statement_type = income` **(ждёт ответа в AURORA-1396)**; до него сервер может отдавать пустые - ОФР: тот же ответ с `statement_type = income` **(ждёт ответа в AURORA-1396)**; до него сервер может отдавать пустые
`aggregates` и `unallocated` для `income`. `aggregates` и `unallocated` для `income`.

View File

@ -282,9 +282,9 @@ components:
deviation_percent: { $ref: '#/components/schemas/Percent' } deviation_percent: { $ref: '#/components/schemas/Percent' }
exceeds_threshold: exceeds_threshold:
type: boolean type: boolean
description: deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой description: deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой; у правила min_or_full — всегда false
status: { type: string, enum: [AUTO, DISCREPANCY, ONE_SIDED, MANUAL] } status: { type: string, enum: [AUTO, DISCREPANCY, ONE_SIDED, MANUAL] }
eliminated_amount: { $ref: '#/components/schemas/Money' } eliminated_amount: { $ref: '#/components/schemas/Money', description: 'Автосумма агрегата по elimination_mode (min / min_or_full / manual; 05-domain-rules.md §8 «КФО» п. 4); её исключает КФО, если нет корректировки' }
composition_changed: { type: boolean, description: Состав или суммы изменились после применённой корректировки } composition_changed: { type: boolean, description: Состав или суммы изменились после применённой корректировки }
correction: correction:
oneOf: oneOf:

181
dev/api-contract-kfo.md Normal file
View File

@ -0,0 +1,181 @@
# Контракт API результата КФО (AURORA-1546 / AURORA-1547)
Контракт вкладки «КФО» отчёта версии консолидации — **до реализации** (docs #206, OpenSpec change
`kfo-calculation-contract`). Машиночитаемая копия — `dev/api-contract-kfo.openapi.yaml` с тремя полными примерами
ответа (их можно брать как фейк для экрана); она генерируется `scripts/kfo_contract/gen_kfo_openapi.py` из
сквозного примера §8 (`scripts/kfo_contract/kfo_example.py`, там же проверки правил) — руками не править. Когда расчёт появится в `arm-core`, копия выгружается из `app.openapi()` и
сверяется тестом, как у гудвилла; если файлы разойдутся с кодом — прав код, оба файла правятся вслед за ним.
Источники, в порядке приоритета:
1. `05-domain-rules.md` §8 «КФО» — алгоритм (п. 1–12) и сквозной пример. Здесь формулы не повторяются — только
ссылки «п. N».
2. DBML v5.26: `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `consolidation_version`.
3. DOC-274647 «Экран: КФО» (черновик) и макет `[31-04]`, `[31-04-01]` — состав колонок, состояния экрана.
## 1. Общее
Как в `dev/api-contract-corrections.md` §1 и `dev/api-contract-goodwill.md` §1:
- префикс `/api/v1`, `Authorization: Bearer <JWT>`;
- **чтение — право `KFO_VIEW`** (не `CONSOLIDATION_VIEW`, как у корректировок и гудвилла: право в seed есть и означает
ровно это), **правки — `CONSOLIDATION_EDIT`** (`KFO_EDIT` в seed нет);
- версия вне видимости — 404 `NOT_FOUND`, как несуществующая;
- доменные ошибки — `{"detail": {"code", "message", ...}}`, 422 валидации тела — массив pydantic;
- **деньги — строки с двумя знаками в тыс. руб.** (`"588500.00"`), поле `units = "thousand_rub"`. Это отличие от
корректировок и гудвилла, где рубли: результат КФО по определению в тысячах (округление — часть расчёта, §8 «КФО»
п. 6), рубли из него не восстановить. Переключатели «Единицы» (тыс. / млн) и «Знаков после запятой» — только показ;
- проценты — строки с двумя знаками (`"70.42"`), `null` — не определён (нулевая база);
- порядок проверок правок: право (403) → видимость (404) → версия редактируема (409 `VERSION_NOT_EDITABLE`,
`LOCK_REQUIRED`, `LOCKED_BY_OTHER`) → операция. Фронт берёт `editable` / `read_only_reason` из ответа и правило сам не
повторяет; у пользователя без `CONSOLIDATION_EDIT` — `read_only_reason = "VERSION_NOT_EDITABLE"`.
**Фронт ничего не считает.** Доли, итоги, «в т. ч. взаимные», расхождения, базы и пределы порогов, признак превышения
и подсветка строки базы приходят с сервера.
## 2. Понятия
- **Строки** — бланк ББ и ОФР редакции на отчётную дату версии (66н до 31.12.2024, ФСБУ 4 позже) плюс служебная
`GOODWILL` в разделе I сразу после 1170 (§8 «КФО» п. 1, 8). Приходят все строки бланка в его порядке (`ordinal`),
включая нулевые; `is_total` — итоговая строка.
- **По компании** (`companies[]` строки, порядок — как `KfoResultOut.companies`): «По данным клиента»
(`client_amount`), «В том числе взаимные обязательства / обороты» (`vgo_amount`), «Очищенные показатели»
(`cleaned_amount = client_amount − vgo_amount`), «Доля в показателе по группе, %» (`share_percent`; `null` при нулевом
итоге строки или `|доля| ≥ 1000 %`). Строки расходов
(2120, 2210, 2220, 2330, 2350) — со знаком минус, и их `vgo_amount` тоже (п. 2, 6). У `GOODWILL` разреза нет.
- **По строке:** `sum_amount` (Σ клиента), `vgo_amount` (Σ взаимных = `eliminated_auto + eliminated_manual`),
`goodwill_adjust` (у `GOODWILL`, 1100, 1600), **`bank_amount`** — «КФО, подготовленная банком»
(`sum_amount − vgo_amount + goodwill_adjust`), `deviation_amount` — «Расхождения» (у итогов `null`), `comment`.
- **Сводка расхождений** (`summary.balance`, `summary.income`) — две плашки экрана: сумма расхождений, база (`base_kind`:
ЧА / ВБ / прибыль от продаж / выручка), предел и процент от базы, `exceeded` — красная плашка (п. 10). При превышении
у строки базы `is_threshold_base = true` — подсветка.
- **Обоснование** (`threshold_justification`) — принятие риска при превышении; `justification_required` — превышение
есть хотя бы по одному отчёту. Обязательность перед отправкой на верификацию проверяет переход статуса (AURORA-2002),
не этот API.
- **Реквизиты формы** (`requisites`, п. 11): ГК, ГСК, отчётная дата, номер и статус версии, сценарий, подход (пакет
документов), причины отклонений от Методики (`methodology_deviations` — редактируется здесь), решение УОБ (только
чтение), корректировки — число агрегатов с применённой корректировкой и Σ их исключений.
- **Предупреждения** (`warnings[]`, п. 12) — не блокируют: `FORM_MISSING`, `FORM_AMBIGUOUS`, `FORM_UNITS_UNKNOWN`,
`LINE_OUTSIDE_BLANK`, `UNALLOCATED_ROWS` (`count` — строк), `INCOME_RULES_MISSING`, `GOODWILL_NOT_APPLIED`,
`GOODWILL_ROW_REASSIGNED`, `CORRECTION_SUPERSEDED_BY_GOODWILL` (корректировка агрегата правила 2 не применена —
пару закрыл гудвилл). Тексты `message` — с сервера, фронт показывает как есть.
## 3. Эндпоинты
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `GET /consolidations/versions/{version_id}/kfo` | `KFO_VIEW` | — | 200 `KfoResultOut` | 404 `NOT_FOUND` |
| `PUT /consolidations/versions/{version_id}/kfo/annotations` | `CONSOLIDATION_EDIT` | `KfoAnnotationsIn` | 200 `KfoResultOut` | 404 `NOT_FOUND`, 409 правок версии, 422 `UNKNOWN_LINE`, 422 валидации тела |
Выгрузки («Таблица», «Форма 2.03.01.01с») — AURORA-1565, отдельным контрактом.
### 3.1 `GET …/kfo` — результат
Весь результат одним ответом, без страниц. **Пока версия не заморожена, ответ — по текущим данным версии**: если
что-то изменилось после `computed_at` (документы, сопоставление, разнесение, корректировки, гудвилл), сервер
пересчитывает и сохраняет результат до ответа (как он это обнаруживает — AURORA-1417). Кнопки «Рассчитать» нет. У
замороженной версии (`is_frozen = true`, ставится при «Верифицирован») — сохранённый результат без пересчёта,
`editable = false`.
Версия без компаний или без форм — не ошибка: строки бланка с нулями и `warnings` (`FORM_MISSING` по каждой компании
и отчёту).
### 3.2 `PUT …/kfo/annotations` — ручные поля
Тело:
```json
{
"comments": [{"statement_type": "balance", "line_code": "1520", "comment": "Акт сверки на 01.10 не получен"}],
"threshold_justification": "Расхождение по 1520 — сверка в работе; риск принят",
"methodology_deviations": null
}
```
- Поле, которого нет в теле, не меняется; `null` — очищает. Пустой объект `{}` — допустим, ничего не пишет.
- `comments[]` — только изменённые строки; `comment` ≤ 2000 символов, `null` или пустой после обрезки — очистить.
Строки нет в бланке версии (или `GOODWILL` в `income`) — 422 `UNKNOWN_LINE` с `detail.statement_type`,
`detail.line_code`; ничего не сохраняется.
- `threshold_justification`, `methodology_deviations` — ≤ 4000 символов; длиннее — 422 массивом pydantic.
- Обоснование можно сохранить и без превышения — сервер его не стирает; `justification_required` от этого не меняется.
- Всё одной транзакцией; комментарии и обоснование переживают пересчёт (по `statement_type`, `line_code`).
- Аудит — одно событие `KFO_EDIT` на запрос (`object_type = kfo_result`, раздел `KFO`), `before` / `after` — только
изменённые поля. Запрос без изменений аудит не пишет.
Ответ — `KfoResultOut` целиком (с пересчётом, если он нужен).
## 4. Схемы
Полностью — в OpenAPI. Кратко:
```
KfoResultOut
version_id uuid; computed_at date-time; is_frozen bool; editable bool; read_only_reason enum|null
units "thousand_rub"
companies[] {legal_entity_id, name, inn} — порядок колонок (по имени)
requisites KfoRequisitesOut
summary {balance: KfoThresholdOut, income: KfoThresholdOut}
justification_required bool; threshold_justification string|null
goodwill {enabled bool, total money}
balance, income {lines: KfoLineOut[]}
warnings[] KfoWarningOut {code, message, legal_entity_id?, statement_type?, count?}
KfoLineOut
line_code, line_name, ordinal, is_total, is_threshold_base
companies[] {legal_entity_id, client_amount, vgo_amount, cleaned_amount, share_percent|null} (у GOODWILL — [])
sum_amount, vgo_amount, eliminated_auto, eliminated_manual, goodwill_adjust, bank_amount money
deviation_amount money|null (у итогов null)
comment string|null
KfoThresholdOut
deviation_total money; base_kind NET_ASSETS|BALANCE_TOTAL|SALES_PROFIT|REVENUE; base_amount money
limit_percent "10.00"|"1.00"; limit_amount money; deviation_percent pct|null; exceeded bool
KfoRequisitesOut
company_group_name, related_group_name|null, reporting_date, version_no, version_status, scenario, approach
methodology_deviations|null, uob_decision_no|null, uob_decision_date|null
corrections_balance, corrections_income {count int, amount money}
KfoAnnotationsIn {comments?: [{statement_type, line_code, comment|null}], threshold_justification?, methodology_deviations?}
```
## 5. Состояния экрана (DOC-274647) → поля ответа
| Состояние | Как видно из ответа |
|---|---|
| Нет форм у части компаний | `warnings[].code = FORM_MISSING` с `legal_entity_id`, `statement_type`; у компании нули |
| ОФР не исключается автоматически | `INCOME_RULES_MISSING` (до AURORA-1999); взаимные обороты — только корректировками |
| Есть нераспределённые строки | `UNALLOCATED_ROWS`, `count` |
| Превышение порога | `summary.*.exceeded = true`, `is_threshold_base` у строки базы, `justification_required = true` |
| Гудвилл учтён | `goodwill.enabled = true`, `goodwill.total`, строка `GOODWILL` с `bank_amount` |
| Только чтение | `editable = false`, `read_only_reason`; при «Верифицирован» ещё `is_frozen = true` |
## 6. Примеры
Три полных ответа — в OpenAPI (`examples` у `GET …/kfo`), на числах сквозного примера §8 «КФО» (три компании, бланк
сокращён до строк примера; настоящий ответ содержит все строки бланка):
- `goodwill_exceeded` — гудвилл включён, превышение порога ББ (42 900 > 37 100), `justification_required = true`;
- `goodwill_disabled` — та же версия без учёта гудвилла: строка `GOODWILL` = 0, 1600 = 580 500;
- `frozen` — «Верифицирован»: `is_frozen`, `editable = false`, обоснование, комментарий 1520, реквизиты УОБ.
Фрагмент `goodwill_exceeded` — строка 1230:
```json
{
"line_code": "1230", "line_name": "Дебиторская задолженность", "ordinal": 40,
"is_total": false, "is_threshold_base": false,
"companies": [
{"legal_entity_id": "1111…", "client_amount": "520000.00", "vgo_amount": "120000.00", "cleaned_amount": "400000.00", "share_percent": "70.42"},
{"legal_entity_id": "2222…", "client_amount": "130000.00", "vgo_amount": "10000.00", "cleaned_amount": "120000.00", "share_percent": "21.13"},
{"legal_entity_id": "3333…", "client_amount": "48000.00", "vgo_amount": "0.00", "cleaned_amount": "48000.00", "share_percent": "8.45"}
],
"sum_amount": "698000.00", "vgo_amount": "130000.00", "eliminated_auto": "100000.00", "eliminated_manual": "30000.00",
"goodwill_adjust": "0.00", "bank_amount": "568000.00", "deviation_amount": "2900.00", "comment": null
}
```
## 7. Открытые вопросы
Варианты, принятые на старте, могут измениться по ответу заказчика — контракт меняется вместе с §8: OQ-180 (строки вне
бланка), OQ-181 (колонки), OQ-182 (значение без формы), OQ-183 (итоговая строка), OQ-184 (округление), OQ-185 (место
гудвилла), OQ-186 (объём расхождений), OQ-187 (ЧА), OQ-188 (нулевая база), OQ-189 (действие при превышении),
OQ-190/191 (подход по Методике, согласование клиентом — полей нет), OQ-192 (пересчёт), OQ-194 (КФО при неполном
комплекте).

File diff suppressed because it is too large Load Diff

View File

@ -639,7 +639,8 @@ variance_percent?, exceeds_threshold, one_sided, skipped_osv_entry_count, osv_en
amount_b, deviation_amount, deviation_percent?, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], amount_b, deviation_amount, deviation_percent?, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[],
osv_entry_ids_b[]}}`, порядок — `rule_no`, затем порядок компаний периметра. `status`: `AUTO` — элиминируется osv_entry_ids_b[]}}`, порядок — `rule_no`, затем порядок компаний периметра. `status`: `AUTO` — элиминируется
`eliminated_amount = min(ΣA, ΣB)` (расхождение ≤ 5 %); `DISCREPANCY` — обе стороны есть, расхождение > 5 % или меньшая сторона `eliminated_amount = min(ΣA, ΣB)` (расхождение ≤ 5 %); `DISCREPANCY` — обе стороны есть, расхождение > 5 % или меньшая сторона
≤ 0, элиминация 0; `ONE_SIDED` — одна из сумм нулевая; `MANUAL` — правило `elimination_mode = manual` (№ 1, № 2), элиминация 0. ≤ 0, элиминация 0; `ONE_SIDED` — одна из сумм нулевая; `MANUAL` — правило `elimination_mode = manual` (№ 1), элиминация 0. Правило № 2 после правки seed в AURORA-1546 —
`min_or_full` (DBML v5.26): `AUTO` при обеих сторонах, `ONE_SIDED` при пустой стороне 2 с `eliminated_amount = ΣA` (§8 «КФО» п. 4).
`osv_entry_ids_*` — строки ОСВ, вошедшие в ΣA и ΣB (по ним — переход в сводную ОСВ). `osv_entry_ids_*` — строки ОСВ, вошедшие в ΣA и ΣB (по ним — переход в сводную ОСВ).
`EliminationPairsOut` — `{version_no, rule_id, rule_no, company_a_id, company_b_id, amount_a, amount_b, deviation_amount, `EliminationPairsOut` — `{version_no, rule_id, rule_no, company_a_id, company_b_id, amount_a, amount_b, deviation_amount,
@ -653,6 +654,13 @@ legal_entity_id, counterparty_id, account_id, account_code, closing_debit?, clos
Суммы — строки в рублях с двумя знаками, со знаком (как в шахматке); `deviation_amount = |ΣA − ΣB|`, `deviation_percent` — Суммы — строки в рублях с двумя знаками, со знаком (как в шахматке); `deviation_amount = |ΣA − ΣB|`, `deviation_percent` —
к меньшей стороне, `null`, если она ≤ 0. Упаковка сумму элиминации не меняет: в КФО идёт `eliminated_amount` тройки. к меньшей стороне, `null`, если она ≤ 0. Упаковка сумму элиминации не меняет: в КФО идёт `eliminated_amount` тройки.
### 5.7.3 Результат КФО (AURORA-1546/1547; контракт до реализации)
Вкладка «КФО» отчёта версии: `GET …/kfo` (право `KFO_VIEW`) и `PUT …/kfo/annotations` (`CONSOLIDATION_EDIT`, блокировка
версии). **Деньги — в тыс. руб.** (`units = "thousand_rub"`), не в рублях, как в остальных разделах. Полностью —
`dev/api-contract-kfo.md` и `dev/api-contract-kfo.openapi.yaml` (три полных примера ответа для фейка); правила —
`05-domain-rules.md` §8 «КФО».
### 5.8 Схемы ответов (кратко) ### 5.8 Схемы ответов (кратко)
Поля с `?` могут быть `null`, но ключ присутствует всегда. Полные схемы — `/openapi.json` и контракты по областям. Поля с `?` могут быть `null`, но ключ присутствует всегда. Полные схемы — `/openapi.json` и контракты по областям.

1614
model/data-model-v5.26.dbml Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 737 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 644 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

270
model/entities-v5.26.md Normal file
View File

@ -0,0 +1,270 @@
# АРМ КА — описание сущностей модели данных v5.26
Источник истины по структуре — `data-model-v5.26.dbml` (полный). Диаграммы по частям — `diagrams/` (part01…09 svg/png; перерисовываются `make diagrams` из текущего DBML). Здесь — что каждая таблица значит для бизнеса, как связана с соседями и откуда взялась.
## Что изменилось в v5.26 (расчёт КФО, AURORA-1546/1547; RSHB/arm-ka-docs#206; миграция — в AURORA-1546)
Номер v5.25 занят PR #203 (факты качества первички, AURORA-1759), ещё не слит; v5.26 построена от v5.24.
| № | Изменение | Решение |
|---|---|---|
| 1 | `kfo_result_line_company` + `client_amount`, `vgo_amount` | «По данным клиента» и «в т. ч. взаимные» по компании (тыс. руб.); `amount` — «очищенные» = `client_amount − vgo_amount`. Без них колонки экрана КФО `[31-04]` из сохранённого результата не восстановить. Разреза «расхождение по компании» нет: на экране «Расхождения» — общая колонка строки |
| 2 | `kfo_result` + `warnings`, `corrections_count_bb/_ofr`, `corrections_amount_bb/_ofr` | Предупреждения расчёта и сводка корректировок для реквизитов формы хранятся с результатом: замороженная версия отдаёт то, что видели при верификации (`context/05-domain-rules.md` §8 «КФО» п. 11–12) |
| 3 | `audit_action` + `KFO_EDIT` | Сохранение ручных полей КФО (комментарии строк, обоснование порога, причины отклонений от Методики) — раздел `KFO`; подходящего действия не было |
| 4 | `elimination_rule.elimination_mode` — значение `min_or_full` | Правило ББ 2 по DOC-274230: `min` при обеих сторонах, сторона 1 при пустой стороне 2, без порога 5 %; строки ДО — каскадом 1310 → 1350 → 1370. Поле varchar — схема не меняется; seed v1 правится на месте в AURORA-1546 |
| 5 | Пояснения `kfo_result_line`, `kfo_result` | Приведены к §8 «КФО»: всё в тыс. руб.; корректировка агрегата заменяет его автосумму (`eliminated_auto` и `eliminated_manual` по одному агрегату не складываются); `GOODWILL` — служебная строка раздела I, входит в 1100/1600 |
Остальные таблицы и enum — как в v5.24.
## Что изменилось в v5.24 (AURORA-1751/1540/1541; RSHB/arm-ka-docs#196; миграция уже слита в arm-core main)
| № | Изменение | Решение |
|---|---|---|
| 1 | Новая таблица `vgo_assignment` | Ручное решение аналитика по строке сводной ОСВ сверх автоматического разнесения по правилам ВГО (`context/05-domain-rules.md` §5): привязка к правилу, снятие привязки, «не учитывать в КФО» у нераспределённой строки. Само разнесение остаётся чистой функцией и не хранится (DECISION-5) — таблица только перекрывает его решением аналитика. Одна действующая запись на строку ОСВ в версии и типе отчётности |
| 2 | Поле `side` не добавлено | Черновик контракта (`dev/api-contract-corrections.md` §5) предполагал отдельную позицию для строки с одновременно дебетовым и кредитовым сальдо; в уже слитой миграции 0011 решение одно на обе позиции строки, не по стороне |
Остальные таблицы и enum — как в v5.23.
## Что изменилось в v5.23 (решения владельца 23.10.2026; RSHB/arm-ka-docs#164, AURORA-1536)
| № | Изменение | Решение |
|---|---|---|
| 1 | `consolidation_version.elimination_rules_version` — `not null` | Версия правил элиминации (`elimination_rule.version_no`), действующая на **дату создания** версии консолидации (OQ-174), фиксируется при создании и дальше не меняется. По ней считаются шахматка по правилу-пресету и пары ВГО (`context/05-domain-rules.md` §4, §5). Вариант «пусто — значит последние открытые правила» отвергнут: после публикации новой версии правил старые консолидации молча пересчитались бы по ней. Версия консолидации без действующих правил не создаётся |
Остальные таблицы и enum — как в v5.22. Реализация — одна миграция в `arm-core`: существующим версиям проставляется номер
версии правил, действовавшей на дату `created_at` (по Москве; если такой нет — последней открытой), затем `NOT NULL`; откат
снимает только ограничение.
## Что изменилось в v5.22 (22.10.2026; AURORA-1446)
Из описания `comment.object_type` убраны устаревшие варианты `chess_cell` и `kfo_result_line`.
Сохранены `document | consolidation_version | …`; сейчас используется привязка к документу. Комментарий к правке пары ВГО хранится
в `correction.comment`, к строкам КФО — в `kfo_result_line.comment` и `client_kfo_line.comment`.
Структура БД не меняется, миграция не требуется. Предыдущие версии модели сохранены.
## Что изменилось в v5.21 (решение владельца 22.10.2026; RSHB/arm-ka-docs#149, AURORA-1442)
| № | Изменение | Решение |
|---|---|---|
| 1 | `audit_action` + `FILE_REPROCESS` | Повторная обработка файла пользователем (`context/05-domain-rules.md` §9.5 п. 13) — отдельное событие журнала, раздел `PRIMARY_DOCUMENTS`. Файл не загружается заново: создаётся новое `recognition_job` с `attempt + 1` и становится `current_recognition_job_id`. `FILE_UPLOAD` отвергнут — это приём нового файла, и журнал показал бы вторую загрузку, которой не было; `FILE_REPLACE` — автозамена одного файла другим. Событие пишется на файл (`object_type = stored_file`): `before_value` — прежнее задание (`job_id`, `attempt`, `status`, `error_code`), `after_value` — новое (`job_id`, `attempt`); повторная обработка пакета версии — по событию на каждый отправленный файл, с `consolidation_id` в `after_value` |
Остальные таблицы и enum — как в v5.20. Реализация — одна миграция в `arm-core`: только добавляет значение enum; откат при
наличии событий `FILE_REPROCESS` останавливается с ошибкой (журнал append-only).
## Что изменилось в v5.20 (решения владельца 21.10.2026; RSHB/arm-ka-docs#147, OQ-161, AURORA-1442)
| № | Изменение | Решение |
|---|---|---|
| 1 | `document`: `is_modified` (not null, default `false`), `original_values` (jsonb), `modified_by` (FK → `app_user`), `modified_at` | Аналитик уточняет **период и счёт документа ОСВ**, если они распознаны неверно (`context/05-domain-rules.md` §9.5 п. 12; OQ-161, вариант 2). Новые значения пишутся **на месте** — в `period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` той же строки `document`; при первой правке прежние значения этих четырёх полей копируются в `original_values` и дальше не меняются; `is_modified` — текущие отличаются от исходных. Так же устроена правка сумм `osv_entry` (`original_values`) — первичка не мутируется: файл, JSON результата в S3 и исходные значения остаются как пришли. Копия `document` (второй документ с другим `file_ordinal`) отвергнута: `file_ordinal` — номер документа в `documents[]` результата, а не номер редакции; копия задвоила бы строки ОСВ, подтверждение и привязки. Строки `osv_entry` при смене счёта не меняются (у них свои коды субсчетов) |
Остальные таблицы и enum — как в v5.19. Реализация — одна миграция в `arm-core`: только добавляет колонки (`is_modified =
false`, остальные `NULL`); откат на данных с `is_modified = true` останавливается с ошибкой (потерялись бы исходные значения).
## Что изменилось в v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116)
Change `openspec/changes/archive/2026-10-19-unified-statement-normalization`: формы ББ/ОФР нормализуются общим кодом сервисов, контракт
результата v2.0; ядро хранит результат как пришёл (`context/05-domain-rules.md` §11, §9.3, §9.5).
| № | Изменение | Решение |
|---|---|---|
| 1 | `financial_statement_line`: снята уникальность `(meta_id, line_code)` (индекс остаётся неуникальным); новые `row_index`, `source_ref` (частичный уникальный `(meta_id, source_ref) WHERE source_ref IS NOT NULL`), `line_status` (enum `statement_line_status`: `ok` / `unreadable` / `missing` / `detail`), `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed` | Повтор кода — законный случай (один код, разные наименования; Р-4): хранятся все строки, чтения «по коду» идут через агрегат формы по коду (сумма строк `ok`, не хранится — DECISION-5). Строки без кода хранятся со статусом (Р-8, отменяет OQ-36 / OQ-141): `detail` — расшифровка под строкой с кодом, в §7, агрегаты, КФО и ЕХ не входит; `unreadable` / `missing` блокируют подтверждение, пока у них есть суммы (Р-13). Порядок и положение в источнике (`row_index`, `source_ref`) нужны экрану, чтобы показать строку и предупреждение у места. **Признак «строка создана правкой аналитика» — `row_index IS NULL`** (прежний признак `line_code_original_value IS NULL` не годится: у строк `unreadable` / `missing` / `detail` источника кода тоже нет); `line_code_original_value` — код из результата как пришёл, правками не меняется |
| 2 | `financial_statement_value`: `source_text`, `needs_review`, `is_confirmed`, `confirmed_by`, `confirmed_at` | `needs_review` **хранится** (пересматривает п. 4 v5.13): его считает сервис («подтверждение проверкой» §7 — Р-5, Р-7), ядро пересчитывает только после правок; подтверждение ячейки без правки аналитиком переживает пересчёт. Сырой текст источника — подсказка ячейки |
| 3 | `financial_statement_column`: `period_kind`, `label_confidence` | Относительные подписи бланка 66н («На отчётную дату», «На 31 декабря предыдущего года») — дата колонки от эффективной даты документа, ядро досчитывает после правки даты отчёта (Р-6); колонка без даты остаётся с `period_kind = none` |
| 4 | `financial_statement_meta`: `form_variant` (enum `statement_form_variant`), `needs_review_fields`, `header_source` (jsonb), `organization_name_original_value`, `organization_name_modified` | Упрощённая форма — свой набор соотношений §7 (`NULL` — документ до v5.19 или результат 1.x, набор полный); поля шапки, требующие проверки (сейчас — неуверенная дата отчёта); сырой текст и уверенность всех полей шапки результата (`<поле>_text`, `<поле>_confidence`) — подсказка у поля на экране; наименование организации правится, как ИНН |
| 5 | Новая таблица `document_issue` + enum `document_issue_kind`, `issue_severity`, `issue_source` | Предупреждения и нарушения §7 документа с уровнем и привязкой к строкам (`line_ids`), колонке (`column_id`, а вне колонок формы — `column_index`), полю шапки; у нарушения — `codes` (коды цели и членов); как пришли от сервиса или посчитаны ядром. Очищается при перезагрузке файла; при пересчёте после правки `relation` / `soft` заменяются целиком, `warning` сервиса сохраняются, кроме снимаемых правкой. **Единственный источник нарушений** для экрана и подтверждения: для результатов 1.x ядро один раз при приёме считает §7 копией `arm_statements` и пишет записи с `source = core`; формы до миграции не пересчитываются (база очищается, Р-24). Ведётся и у документов ОСВ — предупреждения листа `OSV_*` результата 2.0 |
| 6 | `recognition_job.statements_version` | Коммит копии общего кода `arm_statements`, которым посчитан результат (`service.statements_version`); расхождение с копией ядра при пересчёте — `info` |
| 7 | `document`: уникальность `(stored_file_id, document_type, osv_account_code)` — частичная, кроме `BALANCE` / `INCOME`; новая колонка `file_ordinal` + уникальный `(stored_file_id, file_ordinal)` | Вторая форма того же типа в файле хранится (Р-10, Р-15); при двух формах одного типа и периода ни одна не автоподтверждается, подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` (Р-18). ОСВ по счёту, расшифровка и прочий — по-прежнему один на тип на файл (§9.5 п. 2) |
| 8 | `related_group.crm_id` (`varchar`, nullable, unique) | ID ГСК/ГСЗ в ЕЦП CRM из форм `51-03`, `51-04`, `77-02` (AURORA-1619, arm-core#112, OQ-33); вводится вручную, импорт ЕЦП не заполняет (OQ-91); уникален, как `crm_id` ГК и ЮЛ |
Остальные таблицы и enum — как в v5.18. Реализация — одна миграция в `arm-core`; перед выкладкой база очищается от форм
ББ/ОФР (решение владельца 18.10.2026, Р-24), поэтому миграция только добавляет колонки, таблицу и значения по умолчанию и
§7 по существующим формам не считает. Заполнение (миграция проходит и на непустой базе): `row_index` — `NULL` у строк,
созданных правкой (`line_code_original_value IS NULL`), у остальных — по возрастанию `line_code` в форме; `line_status =
ok`, `needs_review` значений — **НЕ** `values_ok`, `source_text = string_value`, `period_kind` — `absolute` при
`period_date`, иначе `none`, `header_source` — из `*_original_value`, `file_ordinal` — по `(created_at, id)` в файле;
`document_issue` пуст — у форм, оставшихся в неочищенной базе, нарушения до первой правки не показываются. Откат
гарантирован на пустой БД и на данных, которые v5.18 представляет без потери первичных данных и ручного ввода (без
повторов кода, строк без кода, двух форм одного типа в файле, `crm_id` групп и правленого наименования организации); на
таких данных `downgrade` останавливается с ошибкой, а производные колонки v5.19 и `document_issue` удаляет с записью в
журнал миграции.
## Что изменилось в v5.18 (решения владельца 09.10.2026, RSHB/arm-ka-docs#90)
| № | Изменение | Решение |
|---|---|---|
| 1 | `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation`) + индекс `(legal_entity_id, consolidation_id)` | Файл загружается **в консолидацию** (период ГК) и становится основой её «рабочего стола» (полный состав стола — `context/05-domain-rules.md` §9.5 п. 11), общего для всех версий периода; `NULL` — загрузка без контекста (страница ЮЛ, монозаёмщики, API). Панель «Первичные документы» в версии показывает рабочий стол консоли для ЮЛ плюс файлы с документами, привязанными к версиям консоли. Что использует конкретная версия — по-прежнему `consolidation_document`; заморозка после `VERIFIED` — привязки и `ex_snapshot`. Привязка файла к **версии** (`consolidation_file`) отвергнута: рабочие версии периода делят первичку, а новая версия после `VERIFIED` подхватывает подтверждённое (`openspec/changes/console-files/design.md`) |
| 2 | Note `recognition_job.reply_deadline` | Без изменения структуры: срок ответа OCR — 20 мин (OQ-159) |
Остальные таблицы и enum — как в v5.17. Реализация — миграция `0006` в `arm-core` (backfill `consolidation_id` по `consolidation_document`).
## Что изменилось в v5.17 (решение владельца 07.10.2026, пересмотр OQ-31, RSHB/arm-ka-docs#53)
| № | Изменение | Решение |
|---|---|---|
| 1 | `legal_entity.kpp` и `legal_entity_history.kpp` (`varchar`, nullable) | КПП юридического лица нужен (макет: колонка «КПП» на экранах `50`, `61`, поле форм `51-02`, `79`; фронтенд: «в ручке получения ЮЛ не приходит КПП»). Формат — 9 символов: 4 цифры, 2 символа (цифра или латинская A–Z), 3 цифры, иначе `INVALID_KPP`. У ИП (`entity_type = ИП`) КПП запрещён. Для ЮЛ необязателен: существующие записи и импорт без колонки остаются валидными. Уникален по-прежнему только `inn` (КПП — атрибут; сопоставление контрагентов ОСВ по ИНН не меняется). Изменение КПП — обычная правка со снимком в `legal_entity_history`. Отменяет п. 2 v5.14 («КПП не вводится») |
Остальные таблицы и enum — как в v5.16. Реализация — миграция `0005` в `arm-core`.
## Что изменилось в v5.16 (Срез 4, решения владельца 06.10.2026)
| № | Изменение | Решение |
|---|---|---|
| 1 | Enum `counterparty_match_kind` (`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`) и колонка `osv_entry.counterparty_match_kind` (nullable) | Способ сопоставления контрагента строки ОСВ хранится. `NULL` ⇔ `counterparty_id` `NULL`; `INN` / `NAME` / `ALIAS` — шаг автоматического резолва при маппинге (ИНН; точное имя; нормализованное имя ЮЛ или алиас); `MANUAL` — ручное сопоставление аналитиком; `PROPAGATED` — автоприменение по имени в версии (DOC-221278 п. 12–13). Вычислять на чтение нельзя: после ручного связывания появляется алиас, и ручное сопоставление неотличимо от `ALIAS`. Миграция `0004` заполняет колонку по уже смапленным строкам (ИНН без пробелов = `legal_entity.inn` → `INN`; `trim(name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS` — приближение: ручных сопоставлений до Среза 4 не было) |
| 2 | Семантика `consolidation_document` | Без изменения структуры: строка создаётся при подтверждении документа (по документам ЮЛ — членов периметра версий «в работе») и при добавлении ЮЛ в периметр (подтверждённые подходящие документы подхватываются); удаляется при замене/удалении файла и исключении ЮЛ из периметра. Для одной (ЮЛ, `document_type`, `osv_account_code`, период, признак нарастающего) привязан один документ — самый поздний `confirmed_at` (D-5 Среза 4) |
| 3 | Семантика `consolidation_lock` | Без изменения структуры: блокировка без истечения по времени — активна, пока её не снял владелец, администратор или не перехватил другой пользователь с `CONSOLIDATION_EDIT` по причине (OQ-119) |
Пробелы квартальной склейки и сама сводная ОСВ по-прежнему не хранятся (DECISION-5). Остальные таблицы и enum — как в v5.15 (43 таблицы; enum — 28). Реализация — миграция `0004` в `arm-core`.
## Что изменилось в 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 | КПП | **Не вводится** *(отменено в v5.17: КПП введён)*. Поле есть только в макете; у `legal_entity` его нет, импорт его не читает (закрывал OQ-31 до v5.17) |
| 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` | Юридическое лицо / ИП. Натуральный ключ — ИНН; **v5.17:** `kpp` — КПП (9 символов, nullable, только у ЮЛ, не ключ). `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` различает. Для ГСК формируется КФО. Иерархия не поддерживается. **v5.19:** `crm_id` — ID группы в ЕЦП CRM (вручную, уникален). | → `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`), версия при замене. **v5.18:** `consolidation_id` — консолидация, в контексте которой файл загружен (основа «рабочего стола консоли», общего для версий периода; полный состав — `context/05-domain-rules.md` §9.5 п. 11; `NULL` — без контекста). Что внутри файла — `document`. Замена — файл целиком со всеми документами. | → `legal_entity`, `consolidation`, `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`. На документ ссылаются разобранные данные, чек-лист, консолидация и ЕХ. **v5.19:** `file_ordinal` — индекс документа в `documents[]` результата (у всех документов); формы ББ/ОФР одного типа в файле могут повторяться (ОСВ по счёту, расшифровка, прочий — один на тип); предупреждения и нарушения — `document_issue`. **v5.20:** период и счёт ОСВ уточняются аналитиком на месте, распознанные — в `original_values` (`is_modified`, `modified_by/at`). | → `stored_file`, `legal_entity`, `app_user`; ← `osv_entry`, `financial_statement_meta`, `document_issue`, `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`). **v5.19:** `statements_version` — версия копии общего кода нормализации, которой посчитан результат форм. | → `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**), код счёта и резолв в план счетов, сырое имя/ИНН контрагента, резолв в ЮЛ и способ сопоставления (`counterparty_match_kind`, v5.16), шесть сумм, тип данных (БУ/валюта/кол-во), «подакцизный» для 90-го. Ручные правки: `is_modified`, `original_values`, `modified_by`. Признак ГСК/ГК/Внешний не хранится — выводится из периметра версии; способ сопоставления контрагента хранится (`NULL` ⇔ контрагент не сопоставлен). | → `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`) и акцепт КА непройденных логических проверок с комментарием. Подтверждение документа — `document.confirmed_by`. **v5.19:** `form_variant` (полная / упрощённая — набор соотношений §7), `needs_review_fields`, `header_source` (сырой текст и уверенность полей шапки), правка наименования (`organization_name_original_value`, `organization_name_modified`); результат проверок §7 и предупреждения хранятся в `document_issue` как пришли от сервиса, ядро пересчитывает их только после правок. | → `document`, `recognition_job`, `app_user`; ← `column`, `line` | ТЗ 6.2/6.3; FNR01; макет |
| `financial_statement_column` | Колонки-периоды формы с датой — нужны для межформенной проверки (1370 на начало года) и гудвилла. **v5.19:** `period_kind` (абсолютная / относительная подпись бланка / без даты), `label_confidence`. | → `meta`; ← `value` | ТЗ 6.3 п.7, 6.7 |
| `financial_statement_line` | Строка формы как в источнике: код (или его сырой текст) и наименование как распознано. **v5.19:** все строки источника — с кодом (`ok`, повторы кода тоже) и без (`unreadable`, `missing`, `detail` — расшифровка под родителем `parent_row_index`); порядок `row_index` (`NULL` ⇔ строка создана правкой аналитика), положение `source_ref`, `needs_review` неуверенного кода, подтверждение кода `is_confirmed`. Раскладка экрана — по коду «как на бумаге». | → `meta`; ← `value`, `document_issue.line_ids` | ТЗ 6.2, Прил. 2 |
| `financial_statement_value` | Число в ячейке (строка × колонка): сумма, уверенность, `is_recognized`, ручная правка с исходным значением. **v5.19:** подсветка — только `needs_review` (хранится: уверенность, `is_recognized` и §7 сведены сервисом, ядро пересчитывает после правок); `source_text` — сырой текст источника; `is_confirmed` / `confirmed_by/at` — подтверждено без правки. (v5.13 этот флаг убирала как производный.) | → `line`, `column`, `app_user` | ТЗ 6.2, Прил. 2 |
| `document_issue` | **v5.19, новая.** Предупреждение или нарушение §7 документа (у формы; у ОСВ — предупреждения листа): вид (`warning` / `relation` / `soft`), код и уровень (`error` / `info`), сообщение, привязка к строкам (`line_ids`), колонке (`column_id` или `column_index`) и полю шапки, источник (`service` / `core`); для нарушения — соотношение, `codes`, `expected`, `actual`. Экран показывает их у места. | → `document`, `financial_statement_column` | change `unified-statement-normalization` (design, шаг 8) |
## Часть 07 — Консолидация
| Сущность | Что это | Ключевые связи | Откуда |
|---|---|---|---|
| `consolidation` | Логическая консолидация: **ГК** + отчётный период. ГСК фиксируется/создаётся при верификации по фактическому периметру. Уникальна по (ГК, год, квартал). **v5.18:** файлы, загруженные в консолидацию, — `stored_file.consolidation_id` (основа рабочего стола, общего для версий периода; полный состав — `context/05-domain-rules.md` §9.5 п. 11). | → `company_group`, `related_group`; ← `consolidation_version`, `stored_file` | ТЗ 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` | «Взять в работу»: кто редактирует версию, перехват с причиной (v5.16: без истечения по времени, OQ-119). Из макета — 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 |
| `vgo_assignment` | Ручное решение аналитика по строке сводной ОСВ сверх автоматического разнесения по правилам ВГО: привязка к правилу / снятие привязки, «не учитывать в КФО» у нераспределённой строки. Разнесение само по себе не хранится — только это решение поверх него. | → `consolidation_version`, `osv_entry` (ON DELETE CASCADE), `elimination_rule`, `app_user` ×2 | DOC-272613 §5, AURORA-1751 |
| `goodwill` | Строка расчёта гудвилла: головная × дочерняя, доля владения, ФВ по 58.01, УК (1310) и добавочный капитал (1350 — вопрос методологам), гудвилл, «включить в КФО», комментарий. | → `consolidation_version`, `legal_entity` ×2, `app_user` | ТЗ 6.7, Прил. 3 п.2, Прил. 8 |
| `kfo_result` | Шапка результата КФО (тыс. руб.): когда посчитан, заморожен ли, ЧА и ВБ, выручка, прибыль, расхождения, пороги, обоснование, итог гудвилла, предупреждения расчёта, сводка корректировок. Одна запись на версию; пока не заморожена — пересчитывается при чтении (§8 «КФО» п. 12). | → `consolidation_version` (1:1); ← `kfo_result_line` | ТЗ 6.4, 6.8, Прил. 10 |
| `kfo_result_line` | Строка консолидированного ББ/ОФР по коду бланка редакции на отчётную дату (66н / ФСБУ 4/2023) и служебная `GOODWILL`: Σ по данным клиента − в т. ч. взаимные (авто или корректировка — не обе по одному агрегату) + гудвилл = КФО банка; расхождение, комментарий. | → `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` | Полиморфный комментарий к объекту (`document | consolidation_version | …`); сейчас используется для документов. Правка и удаление не предусмотрены; аудит не ведётся. | → `app_user`; `object_type + object_id` без FK | ТЗ 6.3; AURORA-1446; макет |
| `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`.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-06

View File

@ -0,0 +1,299 @@
# Design: расчёт КФО — правила §8, модель v5.26, контракт API
Change `kfo-calculation-contract`, issue RSHB/arm-ka-docs#206, ветка `feat/206-kfo-contract`.
Читатели результата — Цыганков (AURORA-1546, расчёт), Степанов (AURORA-1547, экран на фейке). Читают как постановку,
без созвона: документ должен отвечать на «что строить» сам.
## 1. Зачем
С 10.11.2026 в работу идут расчёт и экран КФО (Гант: разработка 10–16.11, тест заказчика 17–25.11). Сейчас у них нет
опоры: §8 описывает КФО по прежней редакции, в модели не хватает колонок по компании, контракта API нет. Цель —
к утру 09.11 дать три документа, по которым бэк и фронт стартуют параллельно и не расходятся.
Успех: Цыганков пишет расчёт по §8 и DBML, не спрашивая «а как тут»; Степанов строит экран по контракту на фейковых
данных, и экран без правок подключается к настоящему бэку.
## 2. Цели, не цели, анти-цели
**Цели**
- §8 «КФО» — полный алгоритм от форм и агрегатов до строки КФО, расхождений и порогов, с примерами.
- DBML v5.26 — всё, что нужно хранить, чтобы отдать экран из `kfo_result` без пересчёта.
- Контракт API КФО — чтение результата и сохранение ручных полей, md + OpenAPI 3.1.
**Не цели** — см. `proposal.md` «Non-goals» (код, ОФР-правила 1999, механизм пересчёта 1417, КФО Общий, подтверждение
клиента, экспорт, проверки переходов, новые поля версии).
**Анти-цели** (провал, даже если документы формально готовы)
- Решения заказчика выданы за принятые. Каждая развилка из «Что решить» DOC-274636 помечается: «на старте — вариант N,
решает заказчик».
- Сумма исключается дважды (автосумма + корректировка, обычный ББ-2 + гудвилл).
- Контракт, который экран не может отрисовать без своих расчётов: фронт не считает ни долей, ни итогов, ни порогов.
- Код роли или номер правила в бизнес-логике: поведение правила задаётся данными (`elimination_mode`), не `rule_no`.
## 3. Ограничения
- Источник истины по полям — DBML; полей, которых нет в v5.26, в §8 и контракте нет.
- Деньги — `numeric(18,2)`, в API — строки с двумя знаками; float нет.
- Производные консолидации не хранятся (DECISION-5); `kfo_result(_line, _line_company)` — хранимый результат версии,
это разрешено моделью; агрегаты ВГО по-прежнему считаются на запрос.
- Соглашения API — как в `dev/api-contract-corrections.md` §1 и `dev/api-contract-goodwill.md` §1 (префикс, права,
404 вне видимости, формат ошибок, порядок проверок правок, `editable` / `read_only_reason`).
- **Нумерация DBML.** В docs открыт PR #203 (v5.25, метрики OCR, AURORA-1759). Наша версия — v5.26. Если #203 слит
раньше нашего PR — строим v5.26 поверх v5.25. Если позже — v5.26 строим от v5.24 и в шапке пишем «v5.25 — PR #203»;
второй слитый PR перед merge вливает изменения первого в свой файл (номера не переписываются задним числом).
- Язык — русский, идентификаторы — английские.
## 4. Что решено
### 4.1 §8 «КФО» — содержание
Блок «КФО» §8 заменяется целиком; блоки «Корректировка», «Гудвилл», «Пороги», «Сравнение с КФО клиента» правятся
только там, где противоречат новому блоку.
**Единицы.** Результат КФО хранится и отдаётся в **тыс. руб.** с двумя знаками. Суммы ОСВ и агрегатов — в рублях,
переводятся в тысячи один раз на ячейке «в т. ч. взаимные» (компания × строка, см. «Округление»).
**1. Строки.** Бланк ББ и ОФР — полный вариант (`full`) той редакции, которую ядро выбирает по **отчётной дате версии**
(как §11.9 и словарь имён ядра: ≤ 31.12.2024 — `66n`, позже — `fsbu4`; `contracts/statement-code-orders.yaml`),
наименования — словарь имён ядра. Для «Русского бисквита» на 01.10.2024 это 66н (1120, 1231, 2930 есть, 1105, 1215,
2420 нет). Плюс служебная строка `GOODWILL` (п. 8). Все строки бланка выводятся, даже нулевые.
Строки форм компаний вне бланка в КФО не входят (предупреждение `LINE_OUTSIDE_BLANK`), строки без кода — тоже
(решение 17.10.2026). На старте — вариант черновика (DOC-274636 вопрос 1): только бланк + гудвилл; решает заказчик.
**2. «По данным клиента» (`client_amount`).** По каждой компании периметра (`consolidation_member`) — подтверждённый
(`CONFIRMED`) документ ББ / ОФР, привязанный к версии (`consolidation_document`). Колонка: ББ — на конец отчётного
квартала, ОФР — за период с начала года по конец квартала (по дате колонки, не по `is_current`; так же, как УК
гудвилла). Строки со статусом `ok`, повторы кода суммируются. Единицы формы — словарём единиц `arm_statements` →
тыс. руб.: «тыс. руб.» как есть, «руб.» / 1000, «млн руб.» × 1000, до двух знаков `ROUND_HALF_UP`.
**Знак строк расходов.** Формы разных компаний могут хранить расходы по-разному (§7, §11.9: скобки, минус или «без
знака»). КФО приводит строки расходов бланка — 2120, 2210, 2220, 2330, 2350 — к одной конвенции: `client_amount =
−|значение|`. Остальные строки — как в форме (1370, 2300, 2400 и прочие могут быть любого знака по существу).
- нет формы → значения компании 0, предупреждение `FORM_MISSING` (вариант 1 вопроса 3 черновика; решает заказчик);
- две и более подтверждённые формы одного типа → 0, `FORM_AMBIGUOUS`;
- единицы не распознаны → 0, `FORM_UNITS_UNKNOWN`.
КФО считается и при неполном комплекте (вопрос 17 черновика: вариант «считать, предупредить»).
**3. Агрегаты.** Источник взаимных сумм — агрегаты таблицы корректировок (§5 п. 2a, `VgoService`): тройка (правило,
компания 1, компания 2) с суммами сторон `amount_1`, `amount_2` (рубли) после ручных решений `vgo_assignment`.
Нераспределённые строки ни в один агрегат не входят и в КФО не исключаются (предупреждение `UNALLOCATED_ROWS` с числом
строк без отметки «не учитывать в КФО»). Тип отчётности агрегата — из правила. Отдельный обход ОСВ в КФО не делается:
то, что видит аналитик в таблице корректировок, и есть вход КФО.
**4. Исключения агрегата.** Каждый агрегат даёт список исключений `(компания, код строки, сумма в рублях ≥ 0)`.
Если у агрегата есть применённая корректировка (`correction.status = APPLIED`) — п. 5, иначе автосумма по режиму
правила (`elimination_rule.elimination_mode`):
| Режим | Автосумма | Куда |
|---|---|---|
| `min` (ББ 3–8) | `min(amount_1, amount_2)`, если обе стороны > 0 и отклонение в допуске (§8 «Корректировка», порог как в коде, OQ-178); иначе 0 | `article_1_line` у компании 1, `article_2_line` у компании 2 |
| `min_or_full` (ББ 2, новое) | обе стороны > 0 → `min(amount_1, amount_2)`; сторона 2 = 0 → `amount_1`; сторона 1 = 0 → 0. Порога нет (DOC-274230 правило 2) | компания 1 — `article_1_line`; компания 2 — каскад по кодам `article_2_line` |
| `manual` (ББ 1) | 0 — только корректировка | — |
| режимы ОФР | по правилам AURORA-1999 (§5); до неё агрегатов ОФР нет | по правилу |
**Каскад** (составная статья «a/b/c»): сумма снимается со строки `a` компании в пределах её доступного остатка
(«по данным клиента» строки в рублях минус уже снятое с неё другими агрегатами, но не ниже 0), остаток — со строки
`b`, затем `c`; что не уместилось — целиком на последнюю строку (DOC-274230: «если остаётся положительный остаток, он
исключается из строки 1370»). Агрегаты обходятся в порядке `rule_no`, затем ИНН компании 1, затем ИНН компании 2 —
порядок детерминирован. Каскад применяется и к составной статье в корректировке, если в ней код с «/» (в коде
корректировки код один, поэтому на практике — только автосумма правила 2).
Изменения seed правил ББ (в AURORA-1546, `arm-core/api/seed/core/elimination_rules_v1.yaml`): правило 2 —
`elimination_mode: min_or_full`, `article_2_line: "1310/1350/1370"`; правка версии 1 на месте, без новой версии
правил (п. 6 «Decisions»).
**Автосумма — одна функция для таблицы корректировок и КФО.** Сейчас `correction_source.py` знает только `manual` и
«остальное как `min`»; с `min_or_full` таблица корректировок показала бы правило 2 с порогом 5 % и односторонний
агрегат нулём, а КФО исключил бы иначе. Поэтому этот PR правит и §8 «Корректировка», и `dev/api-contract-corrections.md`:
у агрегата `min_or_full` `eliminated_amount` = автосумма по таблице выше, `exceeds_threshold = false` всегда, статус —
`AUTO` при обеих сторонах, `ONE_SIDED` с `eliminated_amount = amount_1` при пустой стороне 2. Код — в AURORA-1546:
автосумма агрегата выносится в одну функцию домена, её вызывают и `correction_source`, и расчёт КФО. Правило 1 остаётся `manual`: по DOC-274230 обе его суммы — у компании 2 (Кт 76.7.1 − Дт
76.7.9), а сторона агрегата устроена «компания 1 — дебет, компания 2 — кредит»; автосумма правила 1 — после
переработки seed (follow-up), сейчас только корректировкой.
**5. Корректировка агрегата заменяет автосумму.** Применённая корректировка агрегата отменяет его автосумму целиком
и не прибавляется к ней (DOC-274636 допущение 4; DOC-272613: сохранённые корректировки не меняют суммы сторон).
Исключения: компания 1 — статьи `debit[]` (основная + дочерние), компания 2 — `credit[]`, суммы как введены. Если
заполнены обе стороны и итоги сторон разные, каждая сторона исключает **меньший** итог (ответ Лысенко п. 8, §8
«Корректировка»): снимается со статей стороны по порядку — основная, затем дочерние в порядке ввода — до исчерпания.
Пустая сторона ничего не исключает, а другая — как введена (одностороннее правило 1, ОФР 1/2/3).
**6. «В т. ч. взаимные» по компании (`vgo_amount`).** Σ исключений компании по строке в рублях → тыс. руб., до целых
тысяч, половина — от нуля (`ROUND_HALF_UP` по модулю), со **знаком строки**: строки расходов (2120, 2210, 2220, 2330,
2350; после приведения п. 2 они ≤ 0) получают отрицательное `vgo_amount`. Так на всех строках
одно равенство: `cleaned_amount = client_amount − vgo_amount`, исключение всегда приближает значение к нулю (при
превышении — проходит через ноль). На старте — вариант 1 вопроса 8 черновика (округление ячейки); решает заказчик.
**7. Строка КФО.** По компании: `client_amount`, `vgo_amount`, `cleaned_amount`, `share_percent = cleaned /
bank_amount × 100` (хранится 4 знака, отдаётся 2; `bank_amount = 0` → `null`). По строке: `sum_amount = Σ client`,
`vgo_amount = Σ vgo по компаниям` (= `eliminated_auto + eliminated_manual`, где auto — от агрегатов без корректировки,
manual — от агрегатов с корректировкой; гудвилл-пары — в auto), `goodwill_adjust` (у `GOODWILL` и у итогов, куда он
входит), `bank_amount = sum_amount − vgo_amount + goodwill_adjust`.
**Итоговые строки** (1100, 1200, 1300, 1400, 1500, 1600, 1700, 2100, 2200, 2300, 2400, 2500): «по данным клиента» — итог
из формы компании, «в т. ч. взаимные» — Σ `vgo_amount` слагаемых по формуле бланка с их знаками (1100 ← 1110…1190;
1600 ← 1100 + 1200; 1700 ← 1300 + 1400 + 1500; 2100 ← 2110 + 2120; 2200 ← 2100 + 2210 + 2220; дальше по бланку);
очищенные — разность. **Гудвилл в итогах:** `goodwill_adjust` итоговой строки = Σ `goodwill_adjust` её слагаемых, то
есть `G` у 1100 и 1600; разреза по компаниям у этой части нет, поэтому `bank_amount` итога = Σ очищенных компаний +
`goodwill_adjust`, а доли компаний у 1100/1600 считаются от `bank_amount` и в сумме дают меньше 100 % на долю гудвилла.
Проверка сходимости: `bank(1600) = bank(1700)`, если сходятся формы компаний (у ГО −ФВ, у ДО −P, гудвилл +G, ФВ − P = G). Итог КФО совпадает с подписанным клиентом минус взаимные, а не с суммой своих строк
(вариант 2 вопроса 7 черновика; решает заказчик). Пример: 10 млн исключены из 2110 «Альфы» и 2120 «Беты» — у 2100
каждой компании `vgo` ±10 000, у строки 2100 КФО Σ `vgo` = 0, валовая прибыль группы не меняется.
**8. Гудвилл** (DOC-259025, §8 «Гудвилл»). Ветка гудвилла идёт **по паре ГО → ДО таблицы гудвилла, независимо от
агрегатов**: для пары с `use_goodwill` (действующая, отмечена, доля и комментарий, `G > 0`) при `goodwill_enabled` у
версии — исключения ГО `1170` на ФВ (как в таблице гудвилла — все строки 58.01 и потомков пары), ДО `1310` на P (без
каскада), `G` — в строку `GOODWILL`; агрегат правила 2 той же пары (компания 1 = ГО, компания 2 = ДО), если он есть,
**не применяется** — ни автосумма, ни корректировка. Если строку 58.01 этой пары аналитик перенёс в другое правило
или отметил «не учитывать», она всё равно входит в ФВ — предупреждение `GOODWILL_ROW_REASSIGNED`: ВГО по ней могут
исключиться дважды, решает аналитик. При `G ≤ 0` или выключенном гудвилле — обычный агрегат (п. 4–5). Строка `GOODWILL` —
раздел I, сразу после 1170, без разреза по компаниям, `goodwill_adjust = bank_amount = Σ G` применимых пар в тыс. руб.
(до целых тысяч); входит в итоги 1100 и 1600; `kfo_result.goodwill_total` — то же значение. Место строки: по DOC-259025
(R-1) — отдельная строка раздела I; макет `[31-04]` показывает «В т.ч. Гудвилл» под 1170 — расхождение в открытых
вопросах, решают методологи.
**9. Расхождения по статьям.** По агрегатам режима `min` (ББ и ОФР): `|amount_1 − amount_2|` — на статью стороны с
большей суммой (компания 1 — `article_1_line`, компания 2 — `article_2_line`), независимо от корректировок (вариант 1
вопроса 10 черновика; решает заказчик). Агрегаты `manual`, `min_or_full` и гудвилл-пары в расхождения не входят. По
строке — Σ в рублях → тыс. руб. до целых. `total_deviation_bb` / `total_deviation_ofr` — Σ по строкам своего отчёта.
`has_deviations` — хотя бы одна строка ≠ 0.
**10. Пороги** (ТЗ 6.8, DOC-274636 правило 8; величины — КФО банка, тыс. руб.):
- ББ: ЧА = строка 1300 (вопрос 11, вариант FNR03), ВБ = 1600. `ЧА ≥ 10 % ВБ` → база ЧА, предел 10 % ЧА; иначе (в том
числе ЧА ≤ 0) → база ВБ, предел 1 % ВБ.
- ОФР: `2200 ≥ 0` и `2200 / 2110 ≥ 10 %` → база 2200, предел 10 % 2200; иначе → база 2110, предел 1 % 2110. `2110 = 0`
→ база 2110, предел 0.
- Превышение — `total_deviation > предел` (равно — норма). Процент расхождения от базы — для сводки; база = 0 → `null`.
- Превышение хотя бы по одному отчёту → нужно обоснование (`threshold_justification`). Обязательность перед отправкой
на верификацию (вариант 2 вопроса 13) проверяет переход статуса — AURORA-2002 / 1414, не КФО.
**11. Реквизиты формы** (ТЗ 6.4, DOC-274636 правило 9): ГК, ГСК, отчётная дата, номер и статус версии, сценарий,
подход (`approach` — пакет документов), гудвилл учтён да/нет и сумма, расхождения ББ/ОФР и превышения, обоснование,
корректировки (число агрегатов с применённой корректировкой и Σ их исключений по типу отчёта), причины отклонений от
Методики (`consolidation_version.methodology_deviations`, ввод на вкладке), решение УОБ (номер, дата — только чтение).
«Подход по Методике» и «согласовано клиентом» — не делаем до ответа заказчика (вопросы 14, 15).
**12. Пересчёт и заморозка.** Результат хранится у версии (`kfo_result`, одна запись). Пока версия не заморожена,
чтение отдаёт результат по текущим данным версии: если входы изменились после `computed_at`, сервер пересчитывает и
сохраняет до ответа (как обнаруживать изменения — AURORA-1417). Кнопки «Рассчитать» нет (вариант 1 вопроса 16).
Комментарии строк и обоснование переносятся при пересчёте по (тип отчёта, код строки). При «Верифицирован»
`is_frozen = true` (ставит AURORA-2002) — дальше результат только читается.
**Предупреждения** — `{code, message, legal_entity_id?, statement_type?, count?}`, не блокируют расчёт: `FORM_MISSING`,
`FORM_AMBIGUOUS`, `FORM_UNITS_UNKNOWN`, `LINE_OUTSIDE_BLANK`, `UNALLOCATED_ROWS`, `INCOME_RULES_MISSING` (до 1999 —
агрегатов ОФР нет, взаимные обороты = 0), `GOODWILL_NOT_APPLIED` (гудвилл включён, а применимых пар нет),
`GOODWILL_ROW_REASSIGNED` (п. 8). Предупреждения и сводка корректировок (п. 11) — часть результата: **хранятся в
`kfo_result`** и пишутся при расчёте, чтобы замороженная версия отдавала ровно то, что видели при верификации.
§8 содержит сквозной пример: две компании, правила 2 (с гудвиллом и без), 6 (в допуске и с корректировкой), ОФР-пара
2110/2120, итоги 1100/1600/2100, пороги — числа сходятся до тысячи. Плюс прогон на «Русском бисквите» 01.10.2024
(эталон `Консолидация_Группа РБ.xlsx`) — как критерий 1546, с известными расхождениями: алгоритм по ТЗ (min, порог 5 %),
а эталон исключает каждую сторону полностью (OQ-155).
### 4.2 DBML v5.26
- `kfo_result_line_company`: **+ `client_amount numeric(18,2)`** («по данным клиента», тыс. руб.),
**+ `vgo_amount numeric(18,2)`** («в т. ч. взаимные», со знаком строки); `amount` — пояснение «очищенные показатели»;
`share_percent numeric(7,4)` — «4 знака хранения, в API 2». Строка `GOODWILL` разреза не имеет.
- `kfo_result_line`: пояснения по §8 — единицы тыс. руб.; `sum_amount` = Σ client; `eliminated_auto` / `eliminated_manual`
— от агрегатов без / с корректировкой, **не складываются по одному агрегату**; `vgo_amount = auto + manual`;
`goodwill_adjust` — у `GOODWILL` и итогов 1100/1600 (решение #12); `consolidated_amount = sum − vgo + goodwill_adjust`; `deviation_amount` —
п. 9; `comment` ≤ 2000, переживает пересчёт; `source_kind` — всегда `balance` (ОСВ как источник — вопрос 3).
- `kfo_result`: пояснения порогов по п. 10, `goodwill_total` по п. 8, `is_frozen` — ставит переход «Верифицирован».
- `kfo_result`: **+ `warnings jsonb`** (массив предупреждений расчёта, п. 12), **+ `corrections_count_bb int`,
`corrections_count_ofr int`, `corrections_amount_bb numeric(18,2)`, `corrections_amount_ofr numeric(18,2)`** (число
агрегатов с применённой корректировкой и Σ их исключений в тыс. руб., п. 11).
- `audit_action`: **+ `KFO_EDIT`** — сохранение ручных полей КФО (комментарии строк, обоснование порога, причины
отклонений от Методики) одним событием на запрос, `object_type = kfo_result`, `before` / `after` по изменённым
полям; раздел — `KFO` (в `audit_section` он есть; исполнение плана, R-1 ledger). Миграция enum — в 1546.
- `elimination_rule.elimination_mode`: в пояснение — значение `min_or_full` (varchar, миграция схемы не нужна).
- Шапка — changelog v5.26; `entities-v5.26.md` — копия с теми же правками; ссылки «v5.24» в `CLAUDE.md` workspace и
`openspec/config.yaml` — на v5.26.
- Миграция `arm-core` (семь колонок nullable — две в `kfo_result_line_company`, пять в `kfo_result` — и значение
`audit_action`) — в AURORA-1546.
Разреза «расхождение по компании» не добавляем: на экране «Расхождения» — общая колонка строки.
### 4.3 Контракт API (`dev/api-contract-kfo.md` + `.openapi.yaml`)
Префикс `/api/v1/consolidations/versions/{version_id}`.
| Метод и путь | Право | Ответ | Ошибки |
|---|---|---|---|
| `GET …/kfo` | `KFO_VIEW` | 200 `KfoResultOut` | 404 `NOT_FOUND` |
| `PUT …/kfo/annotations` | `CONSOLIDATION_EDIT` | 200 `KfoResultOut` | 404, 409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`, 422 `UNKNOWN_LINE`, 422 pydantic |
Чтение — по `KFO_VIEW` (право есть в seed и означает ровно это), не по `CONSOLIDATION_VIEW`, как корректировки и
гудвилл; правка — `CONSOLIDATION_EDIT` (`KFO_EDIT` в seed нет).
`KfoResultOut`:
- `version_id`, `computed_at`, `is_frozen`, `editable`, `read_only_reason`, `units = "thousand_rub"`;
- `companies[]` — `{legal_entity_id, name, inn}` в порядке колонок (по имени);
- `requisites` — п. 11;
- `summary.balance` / `summary.income` — `{deviation_total, base_kind (NET_ASSETS | BALANCE_TOTAL | SALES_PROFIT |
REVENUE), base_amount, limit_percent, limit_amount, deviation_percent, exceeded}`;
- `justification_required`, `threshold_justification`;
- `goodwill` — `{enabled, total}`;
- `balance.lines[]`, `income.lines[]` — `KfoLineOut`: `line_code`, `line_name`, `ordinal`, `is_total`,
`is_threshold_base` (подсветка строки базы при превышении), `companies[]` `{legal_entity_id, client_amount,
vgo_amount, cleaned_amount, share_percent}` (у `GOODWILL` — пусто), `sum_amount`, `vgo_amount`, `eliminated_auto`,
`eliminated_manual`, `goodwill_adjust`, `bank_amount`, `deviation_amount`, `comment`;
- `warnings[]`.
Деньги — строки с двумя знаками **в тыс. руб.** (в отличие от корректировок и гудвилла, где рубли: результат КФО по
определению в тысячах; поле `units` явно). Переключатели «Единицы» (тыс. / млн) и «Знаков после запятой» — только показ
на фронте.
`PUT …/kfo/annotations` — тело `{comments?: [{statement_type, line_code, comment | null}], threshold_justification?,
methodology_deviations?}`; отсутствующее поле не меняется, `null` — очищает; комментарий ≤ 2000, обоснование и причины
≤ 4000; строки нет в бланке — 422 `UNKNOWN_LINE`. Сохраняет набором в одной транзакции, аудит — `KFO_EDIT` (4.2).
Ответ — полный `KfoResultOut`.
Состояния экрана из DOC-274647 выражаются полями ответа: нет компаний / нет форм — `warnings`; заморожено — `is_frozen`
+ `editable = false`. Примеры ответа (обычный, с превышением и гудвиллом, замороженный) — в md и в `examples`
OpenAPI, на числах сквозного примера §8; Степанов берёт их как фейк.
### 4.4 Вложения в Jira
После открытия PR (не дожидаясь merge): AURORA-1546 — `05-domain-rules.md` (§8) и `data-model-v5.26.dbml`;
AURORA-1547 — `api-contract-kfo.md` и `api-contract-kfo.openapi.yaml`; в обеих — комментарий со ссылкой на PR. После
правок по ревью — новые версии вложений.
## 5. Что остаётся исполнителю плана
Точный перечень и порядок строк бланка (из словаря ядра и `statement-code-orders.yaml`), тексты предупреждений,
формулировки §8, разметка OpenAPI (имена схем, `examples`), числа сквозного примера, расстановка открытых вопросов в
`07-open-questions.md` (новые OQ с номерами по порядку).
## 6. Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 06.11.2026 | Развилки «решает заказчик» из DOC-274636 закрываются вариантом черновика как стартовым, с пометкой | Раскладка спринта 4, R-2; иначе 1546 не стартует | Доработка по ответу заказчика |
| 2 | 06.11.2026 | Вход КФО — агрегаты таблицы корректировок (§5 п. 2a), не отдельный обход ОСВ | Одна истина для аналитика и КФО; двойной учёт уже снят разнесением | Если заказчик захочет КФО «мимо» разнесения — переделка источника |
| 3 | 06.11.2026 | Корректировка агрегата заменяет автосумму; при разных итогах сторон каждая исключает меньший | DOC-274636 доп. 4, ответ Лысенко п. 8 | Неверная сумма ВГО на агрегатах с корректировкой |
| 4 | 06.11.2026 | Правило 2 считается автоматически по DOC-274230 (режим `min_or_full`, каскад 1310 → 1350 → 1370) правкой seed версии 1 на месте | Описание 1546 (владелец, 06.11) требует каскада; верифицированных версий нет — объяснимость старых результатов не страдает | Версии, созданные до правки, начнут считать правило 2 по-новому; если где-то это недопустимо — нужна версия правил 2 |
| 5 | 06.11.2026 | Правило 1 остаётся ручным (только корректировка) | Обе суммы DOC-274230 — у компании 2, сторона агрегата устроена иначе; переделка seed не влезает | ВГО по 76.7.x не исключаются без корректировки аналитика |
| 6 | 06.11.2026 | Результат КФО в API — в тыс. руб. (поле `units`), не в рублях, как в других контрактах | Округление до тысяч — часть расчёта; рубли из результата не восстановить | Фронт, привыкший к рублям, неверно масштабирует — снимается полем `units` |
| 7 | 06.11.2026 | `vgo_amount` со знаком строки; `cleaned = client − vgo` на всех строках | Расходы в формах с минусом; одно равенство без исключений для экрана и итогов | Если формы хранят расходы положительными — знак у 2120/2210/… перевернётся (проверить на фикстурах в 1546) |
| 8 | 06.11.2026 | Строка `GOODWILL` — отдельная строка раздела I после 1170, без разреза по компаниям, входит в 1100/1600 | DOC-259025 важнее макета (R-1); разрез по компаниям гудвиллу не определён | Если методологи выберут «в т. ч. под 1170» — перенос строки и пересчёт итогов |
| 9 | 06.11.2026 | Чтение пересчитывает устаревший результат сам; кнопки «Рассчитать» нет | Вариант 1 вопроса 16; фронту не нужно знать про устаревание | Долгий GET на больших версиях — оптимизация в 1417 |
| 10 | 06.11.2026 | Разреза «расхождение по компании» в модели нет (в чате обещал добавить) | На экране «Расхождения» — общая колонка строки | Понадобится расшифровка расхождения по компании — новая колонка |
| 11 | 06.11.2026 | Порядок обхода агрегатов для каскада — `rule_no`, ИНН компании 1, ИНН компании 2 | Детерминированный результат при нескольких агрегатах на одну строку | Иное распределение между 1310/1350/1370 при нехватке остатка |
| 12 | 06.11.2026 | Гудвилл в итогах — `goodwill_adjust` у 1100/1600 = Σ слагаемых (вопрос ревью 1, вариант «а») | Одна формула `bank = sum − vgo + goodwill_adjust` на всех строках; 1600 сходится с 1700 | Доли компаний у 1100/1600 в сумме < 100 % — если смутит заказчика, показывать долю гудвилла отдельно |
| 13 | 06.11.2026 | Строки расходов 2120, 2210, 2220, 2330, 2350 приводятся к `−|x|` независимо от знака в форме | Формы хранят знак по-разному (§7, §11.9); без единой конвенции Σ по компаниям и итоги неверны | Законно положительное значение строки расходов (сторно) станет отрицательным |
| 14 | 06.11.2026 | Автосумма агрегата — одна функция для таблицы корректировок и КФО; PR правит §8 «Корректировка» и контракт корректировок под `min_or_full` | Иначе аналитик видит одно, а КФО исключает другое | Шире PR и 1546 (правка `correction_source.py`) |
| 15 | 06.11.2026 | Предупреждения и сводка корректировок хранятся в `kfo_result` (`warnings jsonb`, 4 колонки) | Замороженная версия должна отдавать то, что видели при верификации | Лишние колонки, если ЕХ всё равно заморозит ответ целиком в срез |
| 16 | 06.11.2026 | Аудит ручных полей КФО — новое действие `KFO_EDIT` | В `audit_action` подходящего нет; инвариант «append-only аудит» | Если заказчику нужен отдельный раздел журнала — новое значение раздела |
| 17 | 06.11.2026 | Бланк — по редакции на отчётную дату версии (66н ≤ 2024, ФСБУ 4 позже) | Иначе КФО за 2024 теряет статьи 66н; так же выбирает редакцию ядро | Версия на стыке редакций с формами разных редакций — строки вне бланка в предупреждения |
| 18 | 06.11.2026 | Гудвилл-ветка — по паре таблицы гудвилла, независимо от агрегатов; агрегат правила 2 пары подавляется; перенос строки 58.01 — предупреждение | ФВ гудвилла уже определён по всем строкам 58.01 пары (контракт гудвилла) | Двойное исключение строки, перенесённой аналитиком в другое правило, — видно по предупреждению |
| 19 | 06.11.2026 | Чтение КФО — по `KFO_VIEW`, правка — по `CONSOLIDATION_EDIT` | `KFO_VIEW` в seed есть и означает ровно это; `KFO_EDIT` нет | Роль с `CONSOLIDATION_VIEW` без `KFO_VIEW` не увидит вкладку — так и задумано матрицей |
| 20 | 06.11.2026 | У итоговых строк `deviation_amount = null` | Сумма расхождений актива и пассива в итоге бессмысленна; сводка считается по строкам бланка | Экрану нужна сумма раздела — добавить на фронте (ledger, Task 1) |
| 21 | 06.11.2026 | Примеры контракта: гудвилл + превышение, гудвилл выключен, заморожено; примера «норма» нет | Все три — одна версия сквозного примера, в ней превышение | У фронта нет зелёной сводки в фейке — подменить `exceeded` руками (ledger, Task 1) |
| 22 | 06.11.2026 | `KFO_EDIT` — раздел журнала `KFO`, не `CONSOLIDATION` | `audit_section.KFO` в модели есть и точнее | Фильтр журнала «Консолидация» не покажет правки КФО (ledger, Task 2) |
| 23 | 06.11.2026 | OpenAPI КФО генерируется `scripts/kfo_contract/gen_kfo_openapi.py` из `kfo_example.py`, руками не правится | Числа примеров совпадают с §8; проверки правил в одном месте | Правка руками разойдётся с генератором при следующей перегенерации (ledger, Task 3; ревью M-9) |
| 24 | 06.11.2026 | Порядок исключений: гудвилл-пары, затем агрегаты; остаток для каскада уменьшают все исключения | Ревью I-1: иначе распределение 1310/1350/1370 у ДО с несколькими ГО зависит от реализации | Иное распределение между 1310/1350/1370, если методологи считают иначе |
| 25 | 06.11.2026 | Округляется одна ячейка: `vgo` и `manual` — до тысяч, `auto = vgo − manual` | Ревью I-2: раздельное округление ломает `vgo = auto + manual` | `auto` может отличаться на 1 тыс. от «своей» суммы в рублях |
| 26 | 06.11.2026 | `min_or_full` при `amount_2 ≤ 0` — как пустая сторона (`amount_1`), при `amount_1 ≤ 0` — 0; расхождения — по всем агрегатам `min`, в том числе односторонним | Ревью I-3; односторонний остаток — тоже неисключённое ВГО (вариант 1 OQ-186) | Порог срабатывает чаще из-за односторонних агрегатов |
| 27 | 06.11.2026 | `share_percent` при `|доля| ≥ 1000 %` — `null` (колонка `numeric(7,4)` не расширяется) | Ревью I-4: при почти нулевом итоге доля бессмысленна и переполняет колонку | Экран не покажет экстремальные доли |
| 28 | 06.11.2026 | Итоги — слагаемые с +1 после приведения знака; формулы §7 с минусом к КФО не применяются; 2410/2411 — как в форме (OQ-12); итога нет в форме (упрощённая) — Σ слагаемых | Ревью I-5, I-6 | Σ строки 2410 по компаниям с разной конвенцией знака неверна (на итоги не влияет — итоги из формы) |
| 29 | 06.11.2026 | Корректировка агрегата правила 2, закрытого гудвилл-парой, не применяется и не входит в сводку; предупреждение `CORRECTION_SUPERSEDED_BY_GOODWILL` | Ревью M-5: аналитик видит её в таблице корректировок, а КФО игнорирует | Лишнее предупреждение, если аналитик ввёл её намеренно |

View File

@ -0,0 +1,203 @@
# КФО: §8, DBML v5.26, контракт API — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** к утру 09.11.2026 дать Цыганкову (AURORA-1546) и Степанову (AURORA-1547) правила §8 «КФО», модель v5.26 и
контракт API КФО, по которым расчёт и экран строятся параллельно.
**Architecture:** только документы репозитория `arm-ka-docs`. §8 — алгоритм; DBML — что хранится; контракт — что
отдаёт и принимает API; OpenSpec-delta — требования в форме SHALL/сценариев. Числа сквозного примера §8 и `examples`
контракта — одни и те же, считаются одним скриптом в scratchpad, чтобы не разойтись.
**Tech Stack:** Markdown, DBML, OpenAPI 3.1 (YAML), OpenSpec 1.13.1, `make check`.
**Spec:** `openspec/changes/kfo-calculation-contract/design.md`, `proposal.md`.
Все пути — от корня worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/kfo-contract/docs`; команды — из него.
## Global Constraints
- Результат КФО — тыс. руб., строки с двумя знаками; ОСВ и агрегаты — рубли; float нет.
- `cleaned_amount = client_amount − vgo_amount`; `bank_amount = sum_amount − vgo_amount + goodwill_adjust` — на всех строках.
- Строки расходов, приводимые к `−|x|`: 2120, 2210, 2220, 2330, 2350.
- Новый режим правила: `min_or_full`; правило 2 seed v1: `article_2_line: "1310/1350/1370"`.
- Новые поля: `kfo_result_line_company.client_amount`, `.vgo_amount`; `kfo_result.warnings`, `corrections_count_bb`,
`corrections_count_ofr`, `corrections_amount_bb`, `corrections_amount_ofr`; `audit_action.KFO_EDIT`.
- Коды предупреждений: `FORM_MISSING`, `FORM_AMBIGUOUS`, `FORM_UNITS_UNKNOWN`, `LINE_OUTSIDE_BLANK`, `UNALLOCATED_ROWS`,
`INCOME_RULES_MISSING`, `GOODWILL_NOT_APPLIED`, `GOODWILL_ROW_REASSIGNED`.
- Эндпоинты: `GET /api/v1/consolidations/versions/{version_id}/kfo` (`KFO_VIEW`),
`PUT …/kfo/annotations` (`CONSOLIDATION_EDIT`); ошибка `UNKNOWN_LINE`; `units = "thousand_rub"`.
- Новые открытые вопросы — с OQ-180 (177 и 179 заняты открытыми PR #195 и #203).
- Версия модели — v5.26; v5.25 — PR #203 (если слит до нас — строим поверх него, см. design §3).
- `docs/source/**` не трогать. Язык — русский; маркеры решений — `R-n` с номером design-таблицы.
## Review Focus
1. Агрегат с корректировкой, у которого и автосумма > 0 — исключение ровно одно (корректировка), не сумма двух.
2. Пара гудвилла с `G ≤ 0` или выключенным `goodwill_enabled` — обычный агрегат правила 2, строка `GOODWILL` = 0.
3. Расходная строка 2120 положительной в форме одной компании и отрицательной в другой — после приведения Σ по 2120 и
итог 2100 сходятся.
4. Каскад правила 2 при нехватке остатка 1310 и 1350 — остаток целиком на 1370, Σ исключений ДО = исключению ГО 1170.
5. Версия без форм у части компаний — КФО считается, компания с нулями, `FORM_MISSING` в `warnings`.
Каждый пункт — числом в сквозном примере §8 (Task 1) и в одном из `examples` контракта (Task 3).
---
### Task 1: §8 «КФО», §8 «Корректировка», открытые вопросы
**Files:**
- Modify: `context/05-domain-rules.md` — §8: блок «КФО» заменить; блоки «Корректировка», «Гудвилл» («КФО» в конце),
«Пороги» — привести; §5 п. 1 — строка о режиме `min_or_full`.
- Modify: `context/07-open-questions.md` — OQ-180…: открытые вопросы КФО.
- Create (scratchpad, не в репо): `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/38dca83c-0de5-4c8e-b11b-50b6f660f3f7/scratchpad/kfo_example.py`
**Interfaces:**
- Produces: числа сквозного примера (компании «Альфа», «Бета», «Гамма»; таблица исключений, строки 1170, 1230, 1310,
1350, 1370, 1520, 1100, 1300, 1600, 1700, `GOODWILL`, 2110, 2120, 2100; пороги) — Task 3 копирует их в `examples`.
- [ ] **Step 1: Скрипт сквозного примера.** `kfo_example.py` на `Decimal`: три компании, агрегаты — правило 6
(«Альфа» → «Бета» в допуске 2 %; «Бета» → «Альфа» 20 % с корректировкой, у которой автосумма была бы 0, и ещё один
агрегат 6 в допуске 3 % с корректировкой, где итоги сторон 100/90 — проверка «меньшего»); правило 2 «Альфа» → «Бета»
с гудвиллом (ФВ, УК, d, P, G > 0); правило 2 «Альфа» → «Гамма» без пары на стороне 2 (`min_or_full` → `amount_1`)
с нехваткой 1310/1350 — каскад на 1370; ОФР-агрегат 2110 «Альфа» / 2120 «Бета» (у «Беты» 2120 положительное в форме);
«Гамма» без ОФР — `FORM_MISSING`. Скрипт печатает таблицы строк по компаниям и итоги, пороги ББ/ОФР.
Assert внутри скрипта: `bank(1600) == bank(1700)`; Σ исключений ДО по правилу 2 = исключению ГО 1170; Σ `vgo`
строки 2100 = 0; у агрегата с корректировкой в `vgo` нет автосуммы.
- [ ] **Step 2: Прогнать скрипт.** Run: `python3 -I <scratchpad>/kfo_example.py` — Expected: таблицы печатаются, ни
одного `AssertionError`.
- [ ] **Step 3: Переписать блок «КФО» §8** по design §4.1 п. 1–12 (единицы, строки и редакция бланка, «по данным
клиента» и знак расходов, агрегаты, таблица режимов и каскад, корректировка вместо автосуммы, `vgo` и округление,
строка и итоги, гудвилл, расхождения, пороги, реквизиты, пересчёт и заморозка, предупреждения и их хранение), со
ссылками на источники (DOC-274230, DOC-259025, DOC-272613, DOC-274636) и пометками «на старте — вариант N, решает
заказчик (OQ-nnn)». Сквозной пример — числами из Step 2. Отдельным абзацем — прогон на РБ 01.10.2024 (66н) и
ожидаемое расхождение с эталоном по OQ-155.
- [ ] **Step 4: Привести соседние блоки §8 и §5.** «Корректировка»: агрегат `min_or_full` — `eliminated_amount`,
`exceeds_threshold = false`, статусы `AUTO` / `ONE_SIDED`; одна функция автосуммы для таблицы корректировок и КФО;
«в КФО по выбранным статьям уходит меньшая сумма» — раскрыть по design п. 5. «Гудвилл», абзац «КФО» — ссылка на
новый блок, ветка по паре независимо от агрегата, `GOODWILL` в 1100/1600. «Пороги» — заменить ссылкой на новый блок
(дубли формул убрать). §5 п. 1 — `mode = min_or_full` в псевдокоде.
- [ ] **Step 5: OQ-180… в `07-open-questions.md`.** По одному вопросу на развилку design, которую решает заказчик:
строки вне бланка; состав колонок; значение без формы; итоговая строка; округление; место строки гудвилла;
совокупный объём расхождений; ЧА — 1300 или расчёт; нулевая база порога; действие при превышении; «подход» по
Методике; согласование клиентом; пересчёт автоматически или по кнопке; правило 1 автоматически (follow-up seed). В
каждом — источник, «допущение принято: вариант …», ссылка на §8.
- [ ] **Step 6: Проверка.** Run: `grep -n "eliminated_auto (Σ сумм элиминации троек" context/05-domain-rules.md` —
Expected: пусто (старая формула удалена). Run: `grep -c "OQ-18[0-9]" context/07-open-questions.md` — Expected: ≥ 10.
- [ ] **Step 7: Commit**
```bash
git add context/05-domain-rules.md context/07-open-questions.md
git commit -m "КФО: §8 под DOC-274230/259025/272613, открытые вопросы OQ-180… (#206)"
```
### Task 2: DBML v5.26 и ссылки на версию
**Files:**
- Create: `model/data-model-v5.26.dbml` (копия v5.24 или v5.25 — см. Global Constraints), `model/entities-v5.26.md`.
- Modify: `Makefile:9`, `openspec/config.yaml:4,9`, `context/00-workspace-layout.md`, `context/03-architecture.md`,
`context/04-data-model-guide.md`, `context/08-source-docs-index.md`, `context/KICKOFF-PROMPT.md`,
`repo-templates/arm-core.CLAUDE.md`, `analyst/eliminations-vgo.md` — ссылки v5.24 → v5.26, где речь о текущей версии.
- Modify (если `npx` есть): `model/diagrams/part08_corrections_kfo.{svg,png}` через `make diagrams`.
**Interfaces:**
- Consumes: формулировки полей из Task 1 (§8).
- Produces: имена колонок и enum — ровно как в Global Constraints; Task 3 на них ссылается.
- [ ] **Step 1: Скопировать модель и записать changelog v5.26** в шапку: 1) `kfo_result_line_company` + `client_amount`,
`vgo_amount`; 2) `kfo_result` + `warnings jsonb`, четыре поля сводки корректировок; 3) `audit_action.KFO_EDIT`;
4) `elimination_mode` — значение `min_or_full`; 5) пояснения `kfo_result_line` по §8 (без изменения структуры);
6) миграция — одна в arm-core, AURORA-1546, все новые колонки nullable, откат удаляет колонки, `KFO_EDIT` при
наличии событий не откатывается (как `FILE_REPROCESS` в v5.21).
- [ ] **Step 2: Правки таблиц и enum** по design §4.2 — `note` каждого поля ссылается на §8 п. N.
- [ ] **Step 3: `entities-v5.26.md`** — копия с правками разделов `kfo_result`, `kfo_result_line`,
`kfo_result_line_company`, `audit_event`, `elimination_rule`; шапка — v5.26.
- [ ] **Step 4: Ссылки на текущую версию** в файлах из списка; в `04-data-model-guide.md` — абзац «v5.26».
- [ ] **Step 5: Проверка.** Run: `grep -rn "v5\.24" Makefile openspec/config.yaml context/00-workspace-layout.md context/04-data-model-guide.md`
— Expected: только исторические упоминания («v5.24: таблица vgo_assignment»), не «текущая». Run:
`grep -n "client_amount\|KFO_EDIT\|min_or_full\|corrections_count_bb" model/data-model-v5.26.dbml` — Expected: все
четыре найдены. Если есть `npx`: `make diagrams` — Expected: без ошибок, diff `part08_*`.
- [ ] **Step 6: Commit**
```bash
git add model/ Makefile openspec/config.yaml context/ repo-templates/ analyst/
git commit -m "DBML v5.26: разрез КФО по компании, предупреждения и аудит КФО, режим min_or_full (#206)"
```
### Task 3: Контракт API КФО и правка контракта корректировок
**Files:**
- Create: `dev/api-contract-kfo.md`, `dev/api-contract-kfo.openapi.yaml`.
- Modify: `dev/api-contract-corrections.md` — агрегат `min_or_full`; `dev/frontend-api-guide.md` — строка-ссылка на
контракт КФО в оглавлении контрактов.
**Interfaces:**
- Consumes: числа Task 1 Step 2; имена полей Task 2.
- Produces: схемы `KfoResultOut`, `KfoLineOut`, `KfoLineCompanyOut`, `KfoSummaryOut`, `KfoRequisitesOut`,
`KfoWarningOut`, `KfoAnnotationsIn`, `KfoCommentIn` — их берёт 1547 как фейк и 1546 как схему ответа.
- [ ] **Step 1: `api-contract-kfo.md`** — структура как у `api-contract-goodwill.md`: 1 «Общее» (права `KFO_VIEW` /
`CONSOLIDATION_EDIT`, тыс. руб. и `units`, порядок проверок правок), 2 «Понятия» (кратко со ссылками на §8 п. N, без
повторения формул), 3 «Эндпоинты» (таблица design §4.3, `GET` и `PUT` подробно: пересчёт при чтении, заморозка,
`null` очищает, лимиты 2000/4000, `UNKNOWN_LINE`, аудит `KFO_EDIT`), 4 «Схемы» (поля с типами и смыслом), 5 «Состояния
экрана DOC-274647 → поля ответа», 6 «Примеры» — три ответа (обычный; превышение порога ББ с гудвиллом; замороженный),
сокращённые до строк сквозного примера, 7 «Открытые вопросы» — ссылки на OQ-180….
- [ ] **Step 2: `api-contract-kfo.openapi.yaml`** — OpenAPI 3.1, два пути, схемы из Interfaces, `examples` из Step 1
(полные JSON), коды ошибок как в design §4.3; деньги — `type: string, pattern: '^-?\d{1,16}\.\d{2}$'`, `nullable`
через `type: [string, "null"]`.
- [ ] **Step 3: Валидация OpenAPI.** Run:
`uv run --with openapi-spec-validator python -I -c "from openapi_spec_validator import validate; from openapi_spec_validator.readers import read_from_filename; validate(read_from_filename('dev/api-contract-kfo.openapi.yaml')[0]); print('ok')"`
— Expected: `ok`. Плюс: каждый `example` проходит свою схему — тот же запуск с `jsonschema` по
`components.schemas.KfoResultOut` (скрипт в scratchpad) — Expected: `ok` для трёх примеров.
- [ ] **Step 4: Контракт корректировок.** В `api-contract-corrections.md` §2/§3.1: для правила с `elimination_mode =
min_or_full` — `eliminated_amount` по §8, `exceeds_threshold = false`, статус `AUTO` / `ONE_SIDED`; сноска «изменение
кода — AURORA-1546». Проверить, что в `api-contract-corrections.openapi.yaml` описание поля `status` / `eliminated_amount`
не противоречит (поправить описание, не схему).
- [ ] **Step 5: Commit**
```bash
git add dev/
git commit -m "КФО: контракт API (dev/api-contract-kfo.*), агрегат min_or_full в контракте корректировок (#206)"
```
### Task 4: OpenSpec-delta, проверка, PR, вложения в Jira
**Files:**
- Create: `openspec/changes/kfo-calculation-contract/specs/arm-core/spec.md`, `openspec/changes/kfo-calculation-contract/tasks.md`.
- [ ] **Step 1: Spec delta** — `## ADDED Requirements` в capability `arm-core`: «Расчёт КФО версии» (сценарии: агрегат
с корректировкой; гудвилл G > 0 и G ≤ 0; каскад правила 2; форма отсутствует; приведение знака расходов),
«Чтение результата КФО» (пересчёт при чтении, заморозка), «Ручные поля КФО» (сохранение, `null` очищает,
`UNKNOWN_LINE`, аудит `KFO_EDIT`), «Автосумма агрегата min_or_full» (таблица корректировок и КФО совпадают).
`tasks.md` — четыре задачи этого плана с отметками.
- [ ] **Step 2: Проверка репозитория.** Run: `make validate-openspec CHANGE=kfo-calculation-contract` — Expected: valid.
Run: `make check` — Expected: все цели зелёные.
- [ ] **Step 3: Финальное ревью ветки** (сабагент opus, только чтение): §8 ↔ DBML ↔ контракт — одни имена и числа;
Review Focus 1–5 покрыты; ни одной развилки заказчика без пометки. Находки `C-n/I-n/M-n` — исправить или
`deferred` в design.md.
- [ ] **Step 4: Push и PR** — `git push -u origin feat/206-kfo-contract`; `tea pulls create --login home --repo
RSHB/arm-ka-docs --head feat/206-kfo-contract --base main` с описанием: что и зачем, Closes #206, порядок с #203,
«Rulings» (design §6), таблица находок. Merge — только по команде владельца.
- [ ] **Step 5: Вложения в Jira** — пакет `batch_create`: AURORA-1546 — `context/05-domain-rules.md`,
`model/data-model-v5.26.dbml`; AURORA-1547 — `dev/api-contract-kfo.md`, `dev/api-contract-kfo.openapi.yaml`;
в обе — комментарий голосом владельца со ссылкой на PR (что смотреть: §8 «КФО» / контракт, открытые вопросы OQ-180…).
Expected: все команды пакета `done`.

View File

@ -0,0 +1,48 @@
# Proposal: расчёт КФО — правила §8, модель v5.26, контракт API (docs #206)
## Why
Разработка КФО стартует 10.11.2026: AURORA-1546 (расчёт, Цыганков) и AURORA-1547 (экран, Степанов, на фейковых данных
по контракту). Обе задачи в «Нужно до старта» ссылаются на три документа, которых нет или которые устарели:
- §8 «КФО» в `context/05-domain-rules.md` написан по прежней редакции: корректировки складываются с автоэлиминацией,
нет распределения по строкам правил 1 и 2, нет ветки гудвилла в расчёте, нет единиц и округления;
- в модели `kfo_result_line_company` нет «по данным клиента» и «в т. ч. взаимные» по компании — колонки экрана из
сохранённого результата не восстановить;
- контракта API КФО нет совсем.
Источники: страницы Лысенко — DOC-274230 «Правила пар счетов» (27.10), DOC-259025 «Гудвилл» (03.11), DOC-272613
«Корректировка данных агрегатом» (04.11); черновики команды — DOC-274636 «Расчёт КФО», DOC-274647 «Экран: КФО»;
макет `[31-04]`. При расхождении страница Лысенко важнее черновика (решение владельца 28.10.2026, распространено на КФО
в раскладке спринта 4, R-1).
## What changes
- `context/05-domain-rules.md` §8, блок «КФО» — переписан: источники значений, порядок расчёта, автосумма агрегата по
каждому правилу ББ, распределение по строкам, корректировка агрегата вместо автосуммы, гудвилл, ОФР, итоговые строки,
единицы и округление, расхождения и пороги, реквизиты, пересчёт и заморозка, предупреждения.
- `model/data-model-v5.26.dbml` + `model/entities-v5.26.md` — `kfo_result_line_company` получает `client_amount`
и `vgo_amount`; пояснения `kfo_result_line` (`vgo_amount`, `eliminated_manual`, `GOODWILL`, единицы) и `kfo_result`
приведены к §8; changelog в шапке.
- `dev/api-contract-kfo.md` + `dev/api-contract-kfo.openapi.yaml` — новый контракт: чтение результата КФО версии,
сохранение ручных полей (комментарии строк, обоснование порога, причины отклонений от Методики).
- §8 «Корректировка» и `dev/api-contract-corrections.md` — автосумма агрегата нового режима `min_or_full` (правило 2):
таблица корректировок и КФО считают её одной функцией.
- DBML v5.26 также: `kfo_result.warnings` и сводка корректировок, `audit_action.KFO_EDIT`, пояснение `elimination_mode`.
- `context/07-open-questions.md` — открытые вопросы КФО с вариантом, принятым на старте.
Срез плана: Срез 6 (`09-delivery-plan.md`), Гант 2.5 / 2.8. Дочерние change'и — в `arm-core` (AURORA-1546, 1547,
1417), заводят исполнители.
Схема среза ЕХ `ex-payload-v1` — отдельный change и PR (под AURORA-1560), строится на форме ответа этого контракта.
## Non-goals
- Код расчёта и экрана — AURORA-1546, 1547 в `arm-core`.
- Правила пар ОФР как данные и их расчёт — AURORA-1999 (§5); здесь только как КФО читает агрегаты ОФР.
- Механизм пересчёта и инвалидации (события, триггеры) — AURORA-1417; здесь только наблюдаемое поведение для экрана.
- «КФО Общий» (динамика), «Подтверждение КФО клиента», выгрузка формы 2.03.01.01с — AURORA-2003, 1549/1550, 1565.
- Проверки перед переходами статусов (обязательность обоснования при отправке на верификацию) — AURORA-2002 / 1414;
здесь только признак «нужно обоснование».
- Новые поля версии под «подход по Методике» и «согласовано клиентом» — ждут ответа заказчика (DOC-274636, вопросы 14, 15).
- Схема `ex-payload-v1` — следующий change.

View File

@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: Расчёт КФО версии
Ядро SHALL считать результат КФО версии консолидации по `context/05-domain-rules.md` §8 «КФО» и хранить его в
`kfo_result`, `kfo_result_line`, `kfo_result_line_company` (DBML v5.26) в тыс. руб.: строки бланка редакции на
отчётную дату версии и служебную строку `GOODWILL`; по компании — `client_amount` (строки расходов 2120, 2210, 2220,
2330, 2350 приведены к `−|x|`), `vgo_amount` (Σ исключений компании по строке в рублях, до целых тысяч, половина от
нуля, со знаком строки), `amount = client_amount − vgo_amount`, `share_percent`; по строке — `consolidated_amount =
sum_amount − vgo_amount + goodwill_adjust`. Вход взаимных сумм SHALL быть агрегатами таблицы корректировок (§5 п. 2a);
применённая корректировка агрегата SHALL заменять его автосумму, а не прибавляться к ней.
#### Scenario: Агрегат с корректировкой
- **WHEN** у агрегата правила 6 автосумма 0 (отклонение 400 %) и применена корректировка «Дт Б 1230 10 000 000, Кт А
1520 10 000 000»
- **THEN** у Б по 1230 и у А по 1520 исключается по 10 000 тыс., автосумма агрегата не добавляется; у агрегата в
допуске с корректировкой автосумма тоже не добавляется
#### Scenario: Меньший итог сторон корректировки
- **WHEN** у корректировки агрегата Дт = 20 000 000 (1230) + 5 000 000 (1170), Кт = 22 500 000 (1520)
- **THEN** каждая сторона исключает 22 500 000: компания 1 — 20 000 000 из 1230 и 2 500 000 из 1170, компания 2 —
22 500 000 из 1520
#### Scenario: Каскад правила 2
- **WHEN** агрегат правила 2 (`min_or_full`) без стороны 2 даёт 15 000 000, а у компании 2 строки 1310 = 10 000 тыс.,
1350 = 3 000 тыс.
- **THEN** у компании 1 исключается 15 000 тыс. из 1170, у компании 2 — 10 000 из 1310, 3 000 из 1350, 2 000 из 1370
#### Scenario: Гудвилл учтён
- **WHEN** у пары ГО → ДО таблицы гудвилла `use_goodwill` (ФВ 50 000 000, P 32 000 000, G 18 000 000) и
`goodwill_enabled = true`
- **THEN** у ГО из 1170 исключается 50 000 тыс., у ДО из 1310 — 32 000 тыс., строка `GOODWILL` = 18 000 тыс. и входит в
1100 и 1600, агрегат правила 2 этой пары не применяется, `bank_amount` 1600 равен `bank_amount` 1700
#### Scenario: Гудвилл не учтён
- **WHEN** у той же пары `G ≤ 0` или `goodwill_enabled = false`
- **THEN** применяется агрегат правила 2 как обычно, строка `GOODWILL` = 0
#### Scenario: Расходы с разным знаком в формах
- **WHEN** у компании А строка 2120 хранится как −120 000, у компании Б как +60 000, и 10 000 тыс. исключаются из 2110 А
и 2120 Б
- **THEN** `client_amount` 2120 Б = −60 000, `vgo_amount` = −10 000, `amount` = −50 000; Σ `vgo_amount` строки 2100 = 0
#### Scenario: Нет формы у компании
- **WHEN** у компании периметра нет подтверждённой формы ОФР, привязанной к версии
- **THEN** КФО считается, значения компании по строкам ОФР = 0, в `kfo_result.warnings` — `FORM_MISSING` с компанией и
`statement_type = income`
### Requirement: Автосумма агрегата одной функцией
Автосумма агрегата SHALL считаться одной функцией домена для таблицы корректировок (`eliminated_amount`) и для КФО, по
`elimination_rule.elimination_mode`: `min` — меньшая сторона в допуске, иначе 0; `min_or_full` — меньшая сторона при
обеих > 0, `amount_1` при пустой стороне 2, 0 при пустой стороне 1, без порога (`exceeds_threshold = false`); `manual` — 0.
#### Scenario: Правило 2 в таблице корректировок
- **WHEN** у агрегата правила 2 сторона 2 пуста, `amount_1` = 15 000 000
- **THEN** таблица корректировок отдаёт `status = ONE_SIDED`, `eliminated_amount = "15000000.00"`, `exceeds_threshold =
false`, и КФО исключает ту же сумму
### Requirement: Чтение результата КФО
`GET /api/v1/consolidations/versions/{version_id}/kfo` (право `KFO_VIEW`) SHALL отдавать `KfoResultOut` по
`dev/api-contract-kfo.openapi.yaml`, деньги — строками с двумя знаками в тыс. руб., `units = "thousand_rub"`. Пока
версия не заморожена, ответ SHALL отражать текущие данные версии: устаревший результат пересчитывается и сохраняется
до ответа. У замороженной версии (`kfo_result.is_frozen`) — сохранённый результат без пересчёта, с сохранёнными
`warnings`.
#### Scenario: Изменение после расчёта
- **WHEN** после `computed_at` аналитик сохранил корректировку агрегата
- **THEN** следующий `GET …/kfo` возвращает результат с учётом корректировки и новым `computed_at`
#### Scenario: Замороженная версия
- **WHEN** версия в статусе «Верифицирован» и `is_frozen = true`
- **THEN** ответ — сохранённый результат, `editable = false`, `read_only_reason = "VERSION_NOT_EDITABLE"`, пересчёта нет
### Requirement: Ручные поля КФО
`PUT /api/v1/consolidations/versions/{version_id}/kfo/annotations` (право `CONSOLIDATION_EDIT`, блокировка версии)
SHALL сохранять одной транзакцией комментарии строк (≤ 2000), `threshold_justification` и
`methodology_deviations` (≤ 4000): отсутствующее поле не меняется, `null` очищает; комментарии и обоснование
переживают пересчёт. Изменение SHALL писать одно событие `audit_event(KFO_EDIT)` раздела `KFO` с `before` / `after`.
#### Scenario: Неизвестная строка
- **WHEN** в `comments[]` строка, которой нет в бланке версии
- **THEN** 422 `UNKNOWN_LINE` с `detail.statement_type`, `detail.line_code`, ничего не сохраняется
#### Scenario: Сохранение обоснования
- **WHEN** у версии превышение порога ББ и аналитик с блокировкой сохраняет `threshold_justification`
- **THEN** ответ — `KfoResultOut` с этим обоснованием, в журнале одно событие `KFO_EDIT`

View File

@ -0,0 +1,11 @@
# Tasks: kfo-calculation-contract (docs #206)
План — `plans/2026-11-06-kfo-contract.md`.
- [x] 1. `context/05-domain-rules.md` §8 «КФО» (сквозной пример), §8 «Корректировка», §5 п. 1; `07-open-questions.md` OQ-180…193
- [x] 2. `model/data-model-v5.26.dbml`, `model/entities-v5.26.md`, ссылки на текущую версию, диаграммы
- [x] 3. `dev/api-contract-kfo.md` + `.openapi.yaml`; `api-contract-corrections.*` — агрегат `min_or_full`; `frontend-api-guide.md` §5.7.3
- [x] 4. Spec delta, `make check`, финальное ревью, PR, вложения в AURORA-1546 / AURORA-1547
- [ ] 5. Документы контекста: `03-architecture` (без изменений — архитектура та же), `07-open-questions` (п. 1), `09-delivery-plan` (Срез 6 — после merge кода 1546/1547)
Дочерние change'и — в `arm-core` (AURORA-1546, 1547, 1417); заводят исполнители.

View File

@ -1,12 +1,12 @@
schema: spec-driven schema: spec-driven
context: | context: |
Репозиторий arm-ka-docs — контекст, модель данных (DBML v5.24), правила домена, контракты между сервисами Репозиторий arm-ka-docs — контекст, модель данных (DBML v5.26), правила домена, контракты между сервисами
(docs/contracts/*.schema.json), макет UI и экспорт ТЗ системы АРМ КА (ДКБ, Россельхозбанк). (docs/contracts/*.schema.json), макет UI и экспорт ТЗ системы АРМ КА (ДКБ, Россельхозбанк).
Здесь оформляются СКВОЗНЫЕ изменения: контракт + окружение разработки (docs/dev) + проверки качества (make check) + изменения Здесь оформляются СКВОЗНЫЕ изменения: контракт + окружение разработки (docs/dev) + проверки качества (make check) + изменения
документов контекста. Код сервисов живёт в соседних репозиториях (../arm-core, ../tabular-parser, ../scan-gateway), документов контекста. Код сервисов живёт в соседних репозиториях (../arm-core, ../tabular-parser, ../scan-gateway),
у каждого свой openspec/ и свой change; сквозной change ссылается на них. у каждого свой openspec/ и свой change; сквозной change ссылается на них.
Источники истины по приоритету: docs/model/data-model-v5.24.dbml → docs/model/entities-v5.24.md → Источники истины по приоритету: docs/model/data-model-v5.26.dbml → docs/model/entities-v5.26.md →
docs/context/05-domain-rules.md → docs/source/tz (черновик, имена v4). Открытые вопросы — docs/context/07-open-questions.md; docs/context/05-domain-rules.md → docs/source/tz (черновик, имена v4). Открытые вопросы — docs/context/07-open-questions.md;
план срезов — docs/context/09-delivery-plan.md; соглашения шаблона команды — docs/context/03-architecture.md. план срезов — docs/context/09-delivery-plan.md; соглашения шаблона команды — docs/context/03-architecture.md.
Язык документов и коммитов — русский, идентификаторы — английские, как в DBML. docs/source/** не редактируется. Язык документов и коммитов — русский, идентификаторы — английские, как в DBML. docs/source/** не редактируется.

View File

@ -16,7 +16,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория ## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.24.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. - ORM 1:1 с `../docs/model/data-model-v5.26.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `backend/app/domain/`, без ORM-таблиц. - Вычисляемые объекты консолидации — `backend/app/domain/`, без ORM-таблиц.
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.

View File

@ -0,0 +1,285 @@
"""Генерирует dev/api-contract-kfo.openapi.yaml: пути, схемы и examples из kfo_example.py (docs #206).
uv run --with pyyaml --with jsonschema --with openapi-spec-validator python -I gen_kfo_openapi.py <out.yaml>
"""
import copy
import json
import sys
from decimal import Decimal as D
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).parent))
import kfo_example as ex # noqa: E402
MONEY = {"type": "string", "pattern": r"^-?\d{1,16}\.\d{2}$", "examples": ["588500.00"]}
MONEY_N = {"type": ["string", "null"], "pattern": r"^-?\d{1,16}\.\d{2}$"}
PCT_N = {"type": ["string", "null"], "pattern": r"^-?\d+\.\d{2}$"} # процент к базе не ограничен (база бывает мала)
SHARE_N = {"type": ["string", "null"], "pattern": r"^-?\d{1,3}\.\d{2}$"}
STYPE = {"type": "string", "enum": ["balance", "income"]}
ROR = {"type": ["string", "null"], "enum": ["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER", None]}
WARN = ["FORM_MISSING", "FORM_AMBIGUOUS", "FORM_UNITS_UNKNOWN", "LINE_OUTSIDE_BLANK", "UNALLOCATED_ROWS",
"INCOME_RULES_MISSING", "GOODWILL_NOT_APPLIED", "GOODWILL_ROW_REASSIGNED",
"CORRECTION_SUPERSEDED_BY_GOODWILL"]
def obj(props, required=None, desc=None):
o = {"type": "object", "additionalProperties": False, "properties": props,
"required": list(props) if required is None else required}
if desc:
o["description"] = desc
return o
def ref(n):
return {"$ref": f"#/components/schemas/{n}"}
SCHEMAS = {
"KfoLineCompanyOut": obj({
"legal_entity_id": {"type": "string", "format": "uuid"},
"client_amount": {**MONEY, "description": "«По данным клиента», тыс. руб.; строки расходов приведены к −|x| (§8 «КФО» п. 2)"},
"vgo_amount": {**MONEY, "description": "«В т. ч. взаимные», тыс. руб. до целых, со знаком строки (п. 6)"},
"cleaned_amount": {**MONEY, "description": "«Очищенные показатели» = client_amount − vgo_amount"},
"share_percent": {**SHARE_N, "description": "«Доля в показателе по группе, %» от bank_amount строки, ROUND_HALF_UP; null при нулевом итоге или |доля| ≥ 1000 %"},
}),
"KfoLineOut": obj({
"line_code": {"type": "string", "description": "код строки бланка редакции на отчётную дату или GOODWILL"},
"line_name": {"type": "string"},
"ordinal": {"type": "integer", "description": "порядок строки в бланке"},
"is_total": {"type": "boolean", "description": "итоговая строка бланка (1100, 1600, 2100 …)"},
"is_threshold_base": {"type": "boolean", "description": "строка — база порога (1300, 1600, 2110 или 2200), подсветка при превышении"},
"companies": {"type": "array", "items": ref("KfoLineCompanyOut"),
"description": "по компаниям в порядке KfoResultOut.companies; у GOODWILL — пусто"},
"sum_amount": {**MONEY, "description": "Σ client_amount"},
"vgo_amount": {**MONEY, "description": "Σ vgo_amount = eliminated_auto + eliminated_manual"},
"eliminated_auto": MONEY,
"eliminated_manual": MONEY,
"goodwill_adjust": {**MONEY, "description": "Σ G — у GOODWILL и итогов 1100, 1600"},
"bank_amount": {**MONEY, "description": "«КФО, подготовленная банком» = sum_amount − vgo_amount + goodwill_adjust"},
"deviation_amount": {**MONEY_N, "description": "«Расхождения» (§8 «КФО» п. 9); у итогов — null"},
"comment": {"type": ["string", "null"], "maxLength": 2000},
}),
"KfoStatementOut": obj({"lines": {"type": "array", "items": ref("KfoLineOut")}}),
"KfoThresholdOut": obj({
"deviation_total": MONEY,
"base_kind": {"type": "string", "enum": ["NET_ASSETS", "BALANCE_TOTAL", "SALES_PROFIT", "REVENUE"]},
"base_amount": MONEY,
"limit_percent": {"type": "string", "enum": ["10.00", "1.00"]},
"limit_amount": MONEY,
"deviation_percent": {**PCT_N, "description": "deviation_total / base_amount × 100; null при нулевой базе"},
"exceeded": {"type": "boolean", "description": "deviation_total > limit_amount (равно — норма)"},
}),
"KfoSummaryOut": obj({"balance": ref("KfoThresholdOut"), "income": ref("KfoThresholdOut")}),
"KfoCompanyOut": obj({"legal_entity_id": {"type": "string", "format": "uuid"}, "name": {"type": "string"},
"inn": {"type": "string"}}),
"KfoCorrectionsOut": obj({"count": {"type": "integer", "minimum": 0}, "amount": MONEY},
desc="агрегаты с применённой корректировкой и Σ их исключений, тыс. руб."),
"KfoRequisitesOut": obj({
"company_group_name": {"type": "string"},
"related_group_name": {"type": ["string", "null"]},
"reporting_date": {"type": "string", "format": "date"},
"version_no": {"type": "integer"},
"version_status": {"type": "string", "description": "consolidation_version.status (enum consolidation_status DBML)",
"enum": ["INITIATED", "DOCUMENTS_PROCESSING", "DATA_CORRECTION", "CONSOLIDATION_RF", "ADJUSTMENT",
"VERIFICATION_RF", "CONSOLIDATION_GO", "VERIFICATION_GO", "VERIFIED", "CANCELLED",
"REJECTED", "ARCHIVED"]},
"scenario": {"type": "string", "enum": ["CREATE", "CONFIRM"]},
"approach": {"type": "string", "enum": ["BASIC", "EXTENDED"]},
"methodology_deviations": {"type": ["string", "null"], "maxLength": 4000},
"uob_decision_no": {"type": ["string", "null"]},
"uob_decision_date": {"type": ["string", "null"], "format": "date"},
"corrections_balance": ref("KfoCorrectionsOut"),
"corrections_income": ref("KfoCorrectionsOut"),
}),
"KfoGoodwillOut": obj({"enabled": {"type": "boolean"}, "total": MONEY}),
"KfoWarningOut": obj({
"code": {"type": "string", "enum": WARN},
"message": {"type": "string"},
"legal_entity_id": {"type": ["string", "null"], "format": "uuid"},
"statement_type": {"type": ["string", "null"], "enum": ["balance", "income", None]},
"count": {"type": ["integer", "null"]},
}, required=["code", "message"]),
"KfoResultOut": obj({
"version_id": {"type": "string", "format": "uuid"},
"computed_at": {"type": "string", "format": "date-time"},
"is_frozen": {"type": "boolean"},
"editable": {"type": "boolean"},
"read_only_reason": ROR,
"units": {"type": "string", "const": "thousand_rub"},
"companies": {"type": "array", "items": ref("KfoCompanyOut")},
"requisites": ref("KfoRequisitesOut"),
"summary": ref("KfoSummaryOut"),
"justification_required": {"type": "boolean"},
"threshold_justification": {"type": ["string", "null"], "maxLength": 4000},
"goodwill": ref("KfoGoodwillOut"),
"balance": ref("KfoStatementOut"),
"income": ref("KfoStatementOut"),
"warnings": {"type": "array", "items": ref("KfoWarningOut")},
}),
"KfoCommentIn": obj({"statement_type": STYPE, "line_code": {"type": "string"},
"comment": {"type": ["string", "null"], "maxLength": 2000}}),
"KfoAnnotationsIn": obj({
"comments": {"type": "array", "items": ref("KfoCommentIn")},
"threshold_justification": {"type": ["string", "null"], "maxLength": 4000},
"methodology_deviations": {"type": ["string", "null"], "maxLength": 4000},
}, required=[], desc="Отсутствующее поле не меняется, null — очищает"),
"ErrorDetail": obj({"code": {"type": "string"}, "message": {"type": "string"},
"line_code": {"type": ["string", "null"]},
"statement_type": {"type": ["string", "null"]}}, required=["code", "message"]),
"ErrorOut": obj({"detail": ref("ErrorDetail")}),
}
IDS = {"A": "11111111-1111-4111-8111-111111111111", "B": "22222222-2222-4222-8222-222222222222",
"C": "33333333-3333-4333-8333-333333333333"}
VERSION = "99999999-9999-4999-8999-999999999999"
NAMES_BB = {"1170": "Финансовые вложения", "GOODWILL": "Гудвилл", "1100": "Итого по разделу I",
"1230": "Дебиторская задолженность", "1200": "Итого по разделу II", "1600": "БАЛАНС",
"1310": "Уставный капитал", "1350": "Добавочный капитал (без переоценки)",
"1370": "Нераспределенная прибыль (непокрытый убыток)", "1300": "Итого по разделу III",
"1410": "Заемные средства", "1400": "Итого по разделу IV", "1520": "Кредиторская задолженность",
"1500": "Итого по разделу V", "1700": "БАЛАНС"}
NAMES_OFR = {"2110": "Выручка", "2120": "Себестоимость продаж", "2100": "Валовая прибыль (убыток)",
"2210": "Коммерческие расходы", "2220": "Управленческие расходы", "2200": "Прибыль (убыток) от продаж"}
def m(x):
return None if x is None else f"{D(x):.2f}".replace("-0.00", "0.00")
def lines(rows, order, names, base_code, exceeded, comments=None):
out = []
for i, code in enumerate(order, 1):
r = rows[code]
comps = []
if r["companies"]:
for c in ex.COMPANIES:
x = r["companies"][c]
comps.append({"legal_entity_id": IDS[c], "client_amount": m(x["client"]), "vgo_amount": m(x["vgo"]),
"cleaned_amount": m(x["cleaned"]), "share_percent": m(x["share"])})
out.append({"line_code": code, "line_name": names[code], "ordinal": i * 10,
"is_total": code in ex.TOTALS, "is_threshold_base": exceeded and code == base_code,
"companies": comps, "sum_amount": m(r["sum"]), "vgo_amount": m(r["vgo"]),
"eliminated_auto": m(r["auto"]), "eliminated_manual": m(r["manual"]),
"goodwill_adjust": m(r["goodwill_adjust"]), "bank_amount": m(r["bank"]),
"deviation_amount": m(r["dev"]), "comment": (comments or {}).get(code)})
return out
def result(enabled, frozen=False):
bb, ofr, summ, g = ex.compute(enabled)
base_code = {"NET_ASSETS": "1300", "BALANCE_TOTAL": "1600", "SALES_PROFIT": "2200", "REVENUE": "2110"}
def thr(s):
return {"deviation_total": m(s["deviation_total"]), "base_kind": s["base_kind"],
"base_amount": m(s["base_amount"]), "limit_percent": m(s["limit_percent"]),
"limit_amount": m(s["limit_amount"]), "deviation_percent": m(s["deviation_percent"]),
"exceeded": s["exceeded"]}
comments = {"1520": "Расхождение Бета → Альфа по 62/60: акт сверки на 01.10 не получен"} if frozen else None
warnings = [{"code": "FORM_MISSING", "message": "Нет подтверждённой формы ОФР у ООО «Гамма»",
"legal_entity_id": IDS["C"], "statement_type": "income", "count": None}]
return {
"version_id": VERSION, "computed_at": "2026-11-10T09:15:00Z", "is_frozen": frozen,
"editable": not frozen, "read_only_reason": "VERSION_NOT_EDITABLE" if frozen else None,
"units": "thousand_rub",
"companies": [{"legal_entity_id": IDS[c], "name": ex.NAMES[c], "inn": ex.INN[c]} for c in ex.COMPANIES],
"requisites": {"company_group_name": "ГК «Альфа»", "related_group_name": "ГСК «Альфа»",
"reporting_date": "2025-10-01", "version_no": 1,
"version_status": "VERIFIED" if frozen else "CONSOLIDATION_RF",
"scenario": "CREATE", "approach": "BASIC",
"methodology_deviations": "Отклонений от Методики нет" if frozen else None,
"uob_decision_no": "УОБ-118" if frozen else None,
"uob_decision_date": "2025-11-20" if frozen else None,
"corrections_balance": {"count": 2, "amount": "65000.00"},
"corrections_income": {"count": 0, "amount": "0.00"}},
"summary": {"balance": thr(summ["balance"]), "income": thr(summ["income"])},
"justification_required": summ["balance"]["exceeded"] or summ["income"]["exceeded"],
"threshold_justification": ("Расхождение по 1520 — Бета → Альфа, сверка в работе; риск принят"
if frozen else None),
"goodwill": {"enabled": enabled, "total": m(g / ex.K) if enabled else "0.00"},
"balance": {"lines": lines(bb, ex.BB_LINES, NAMES_BB, base_code[summ["balance"]["base_kind"]],
summ["balance"]["exceeded"], comments)},
"income": {"lines": lines(ofr, ex.OFR_LINES, NAMES_OFR, base_code[summ["income"]["base_kind"]],
summ["income"]["exceeded"])},
"warnings": warnings,
}
def err(desc, code, extra=None):
e = {"description": desc, "content": {"application/json": {"schema": ref("ErrorOut")}}}
return e
PATH_PARAM = [{"name": "version_id", "in": "path", "required": True, "schema": {"type": "string", "format": "uuid"}}]
def build():
ex1, ex2, ex3 = result(True), result(False), result(True, frozen=True)
doc = {
"openapi": "3.1.0",
"info": {"title": "АРМ КА — результат КФО (AURORA-1546/1547)", "version": "0.1",
"description": "Контракт до реализации (docs #206). Правила — context/05-domain-rules.md §8 «КФО»; "
"текст — dev/api-contract-kfo.md. Деньги — строки с двумя знаками в тыс. руб. (units)."},
"paths": {
"/api/v1/consolidations/versions/{version_id}/kfo": {"get": {
"tags": ["consolidations"], "summary": "Результат КФО версии",
"description": "Право KFO_VIEW. Пока версия не заморожена — результат по текущим данным: устаревший "
"пересчитывается и сохраняется до ответа. Замороженный — только чтение сохранённого.",
"operationId": "kfo_get", "security": [{"bearerAuth": []}], "parameters": PATH_PARAM,
"responses": {
"200": {"description": "Результат КФО", "content": {"application/json": {
"schema": ref("KfoResultOut"),
"examples": {
"goodwill_exceeded": {"summary": "Гудвилл включён, превышение порога ББ (сквозной пример §8)", "value": ex1},
"goodwill_disabled": {"summary": "Та же версия, учёт гудвилла выключен", "value": ex2},
"frozen": {"summary": "Версия «Верифицирован»: только чтение, обоснование и комментарий заполнены", "value": ex3},
}}}},
"403": err("Нет права KFO_VIEW", "FORBIDDEN"),
"404": err("Версия не найдена или вне видимости — NOT_FOUND", "NOT_FOUND"),
}}},
"/api/v1/consolidations/versions/{version_id}/kfo/annotations": {"put": {
"tags": ["consolidations"], "summary": "Ручные поля КФО",
"description": "Право CONSOLIDATION_EDIT, блокировка версии. Комментарии строк, обоснование порога, "
"причины отклонений от Методики — одной транзакцией; отсутствующее поле не меняется, "
"null очищает. Аудит KFO_EDIT. Ответ — KfoResultOut целиком.",
"operationId": "kfo_annotations_put", "security": [{"bearerAuth": []}], "parameters": PATH_PARAM,
"requestBody": {"required": True, "content": {"application/json": {
"schema": ref("KfoAnnotationsIn"),
"examples": {"justify": {"summary": "Обоснование и комментарий строки", "value": {
"comments": [{"statement_type": "balance", "line_code": "1520",
"comment": "Расхождение Бета → Альфа по 62/60: акт сверки на 01.10 не получен"}],
"threshold_justification": "Расхождение по 1520 — Бета → Альфа, сверка в работе; риск принят"}}}}}},
"responses": {
"200": {"description": "Результат КФО после сохранения",
"content": {"application/json": {"schema": ref("KfoResultOut")}}},
"403": err("Нет права CONSOLIDATION_EDIT", "FORBIDDEN"),
"404": err("NOT_FOUND", "NOT_FOUND"),
"409": err("VERSION_NOT_EDITABLE | LOCK_REQUIRED | LOCKED_BY_OTHER", "VERSION_NOT_EDITABLE"),
"422": {"description": "UNKNOWN_LINE (detail.statement_type, detail.line_code) или массив ошибок "
"валидации тела (длины 2000/4000)",
"content": {"application/json": {"schema": {"oneOf": [ref("ErrorOut"), {"type": "object"}]}}}},
}}},
},
"components": {"schemas": SCHEMAS,
"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}}},
}
return doc, (ex1, ex2, ex3)
if __name__ == "__main__":
doc, examples = build()
import jsonschema
from openapi_spec_validator import validate
validate(copy.deepcopy(doc))
root = {"$defs": SCHEMAS}
resolver_schema = json.loads(json.dumps(SCHEMAS).replace("#/components/schemas/", "#/$defs/"))
for name, e in zip(("goodwill_exceeded", "goodwill_disabled", "frozen"), examples):
jsonschema.validate(e, {"$defs": resolver_schema, **resolver_schema["KfoResultOut"]},
cls=jsonschema.Draft202012Validator)
print("example ok:", name)
Path(sys.argv[1]).write_text(
"# Сгенерировано scripts/kfo_contract/gen_kfo_openapi.py из сквозного примера §8 «КФО» (docs #206).\n"
"# Руками не править: правка генератора (и kfo_example.py) → перегенерация; текст — dev/api-contract-kfo.md.\n"
+ yaml.safe_dump(doc, allow_unicode=True, sort_keys=False, width=120), encoding="utf-8")
print("openapi ok")

View File

@ -0,0 +1,315 @@
"""Сквозной пример §8 «КФО» (docs #206): мини-движок по design.md §4.1, числа для §8 и examples контракта.
Запуск: python3 -I kfo_example.py [--json out.json]
"""
import json
import sys
from decimal import ROUND_HALF_UP, Decimal as D
K = D("1000")
EXPENSE = {"2120", "2210", "2220", "2330", "2350"}
COMPANIES = ["A", "B", "C"]
NAMES = {"A": "ООО «Альфа»", "B": "ООО «Бета»", "C": "ООО «Гамма»"}
INN = {"A": "7701000001", "B": "7701000002", "C": "7701000003"}
# Формы, тыс. руб. (как в форме; знак расходов у «Беты» — «без знака»)
BB = {
"A": {"1170": 70000, "1230": 520000, "1100": 70000, "1200": 520000, "1600": 590000,
"1310": 100000, "1350": 0, "1370": 280000, "1300": 380000, "1410": 50000, "1400": 50000,
"1520": 160000, "1500": 160000, "1700": 590000},
"B": {"1170": 0, "1230": 130000, "1100": 0, "1200": 130000, "1600": 130000,
"1310": 40000, "1350": 0, "1370": -20000, "1300": 20000, "1410": 0, "1400": 0,
"1520": 110000, "1500": 110000, "1700": 130000},
"C": {"1170": 0, "1230": 48000, "1100": 0, "1200": 48000, "1600": 48000,
"1310": 10000, "1350": 3000, "1370": 5000, "1300": 18000, "1410": 0, "1400": 0,
"1520": 30000, "1500": 30000, "1700": 48000},
}
OFR = {
"A": {"2110": 200000, "2120": -120000, "2100": 80000, "2210": -20000, "2220": -10000, "2200": 50000},
"B": {"2110": 80000, "2120": 60000, "2100": 20000, "2210": -5000, "2220": 0, "2200": 15000},
# «Гамма» — формы ОФР нет: FORM_MISSING
}
BB_LINES = ["1170", "GOODWILL", "1100", "1230", "1200", "1600", "1310", "1350", "1370", "1300",
"1410", "1400", "1520", "1500", "1700"]
OFR_LINES = ["2110", "2120", "2100", "2210", "2220", "2200"]
TOTALS = { # итог ← слагаемые (с учётом знака, значения уже со знаком)
"1100": ["1170", "GOODWILL"], "1200": ["1230"], "1600": ["1100", "1200"],
"1300": ["1310", "1350", "1370"], "1400": ["1410"], "1500": ["1520"], "1700": ["1300", "1400", "1500"],
"2100": ["2110", "2120"], "2200": ["2100", "2210", "2220"],
}
# Агрегаты таблицы корректировок (рубли). rule: (mode, article_1, article_2)
RULES = {2: ("min_or_full", "1170", "1310/1350/1370"), 6: ("min", "1230", "1520"),
"ofr1": ("full_side1_and_side2", "2110", "2120")}
AGGS = [
# (rule, c1, c2, amount_1, amount_2, correction)
(6, "A", "B", D("102000000.00"), D("100000400.50"), None),
(6, "B", "A", D("10000000.00"), D("50000000.00"),
{"debit": [("1230", D("10000000.00"))], "credit": [("1520", D("10000000.00"))]}),
(6, "A", "C", D("30000000.00"), D("29100000.00"),
{"debit": [("1230", D("20000000.00")), ("1170", D("5000000.00"))], "credit": [("1520", D("22500000.00"))]}),
(2, "A", "B", D("50000000.00"), D("40000000.00"), None),
(2, "A", "C", D("15000000.00"), D("0.00"), None),
("ofr1", "A", "B", D("10000000.00"), D("10000000.00"), None), # S без НДС (правила 1999)
]
GOODWILL_PAIRS = [ # ГО, ДО, ФВ, УК (руб.), доля %, отмечена
("A", "B", D("50000000.00"), D("40000000.00"), D("80.00"), True),
]
THRESHOLD = D("5")
def r2(x):
return x.quantize(D("0.01"), ROUND_HALF_UP)
def k_int(rub): # рубли → целые тысячи, половина от нуля
return (rub / K).quantize(D("1"), ROUND_HALF_UP)
def split_vgo(auto_rub, manual_rub, sign):
"""Ячейка компания × строка: vgo — округлённая Σ исключений; manual округляется так же; auto = vgo − manual."""
vgo = sign * k_int(auto_rub + manual_rub)
man = sign * k_int(manual_rub)
return vgo, vgo - man, man
def auto_amount(mode, a1, a2):
"""Автосумма агрегата по elimination_mode (без порога для min — порог проверяется отдельно)."""
if mode == "min":
return min(a1, a2) if a1 > 0 and a2 > 0 else D(0)
if mode == "min_or_full":
if a1 <= 0:
return D(0)
return min(a1, a2) if a2 > 0 else a1
return D(0)
def agg_deviation(mode, a1, a2):
"""Расхождение агрегата для порога: только режим min, в том числе односторонний."""
return abs(a1 - a2) if mode == "min" else D(0)
def share(cleaned, bank):
if not bank:
return None
x = r2(cleaned / bank * 100)
return None if abs(x) >= 1000 else x
def total_client(form_c, line, parts):
"""Итог «по данным клиента»: из формы, а если строки итога в форме нет — Σ слагаемых."""
if line in form_c:
return D(form_c[line])
return sum((D(form_c.get(p_, 0)) for p_ in parts), D(0))
def client(form, c, line):
v = D(form.get(c, {}).get(line, 0))
return -abs(v) if line in EXPENSE else v
def compute(goodwill_enabled=True):
excl = {} # (c, line) -> рубли
auto, manual = {}, {}
dev = {}
log = []
def add(c, line, amt, kind):
excl[(c, line)] = excl.get((c, line), D(0)) + amt
(auto if kind == "auto" else manual)[(c, line)] = (auto if kind == "auto" else manual).get((c, line), D(0)) + amt
log.append((c, line, amt, kind))
def available(c, line):
return max(client(BB, c, line) * K - excl.get((c, line), D(0)), D(0))
def cascade(c, lines, amt, kind):
codes = lines.split("/")
for i, code in enumerate(codes):
take = amt if i == len(codes) - 1 else min(amt, available(c, code))
if take > 0:
add(c, code, take, kind)
amt -= take
if amt <= 0:
break
gw_applied = []
g_total = D(0)
for go, do, fv, uk, d, marked in GOODWILL_PAIRS:
p = r2(uk * d / 100)
g = fv - p
if goodwill_enabled and marked and g > 0:
gw_applied.append((go, do))
add(go, "1170", fv, "auto")
add(do, "1310", p, "auto")
g_total += g
order = sorted(AGGS, key=lambda a: (str(a[0]), INN[a[1]], INN[a[2]]))
for rule, c1, c2, a1, a2, corr in order:
mode, art1, art2 = RULES[rule]
dv = agg_deviation(mode, a1, a2)
if dv:
line_dev = (c1, art1) if a1 > a2 else (c2, art2)
dev[line_dev] = dev.get(line_dev, D(0)) + dv
if rule == 2 and (c1, c2) in gw_applied:
continue # гудвилл-пара подавляет агрегат правила 2
if corr:
dt = sum((x for _, x in corr["debit"]), D(0))
kt = sum((x for _, x in corr["credit"]), D(0))
lim = min(dt, kt) if dt > 0 and kt > 0 else None
for side, c in (("debit", c1), ("credit", c2)):
rest = lim
for line, x in corr[side]:
take = x if rest is None else min(x, rest)
if take > 0:
add(c, line, take, "manual")
if rest is not None:
rest -= take
continue
if mode == "min":
pct = abs(a1 - a2) / min(a1, a2) * 100 if a1 > 0 and a2 > 0 else None
s = auto_amount(mode, a1, a2) if pct is not None and pct <= THRESHOLD else D(0)
if s:
add(c1, art1, s, "auto"); add(c2, art2, s, "auto")
elif mode == "min_or_full":
s = auto_amount(mode, a1, a2)
if s:
add(c1, art1, s, "auto"); cascade(c2, art2, s, "auto")
elif mode == "full_side1_and_side2":
add(c1, art1, a1, "auto"); add(c2, art2, a2, "auto")
def build(form, lines, stype):
out = {}
for line in lines:
if line == "GOODWILL":
ga = k_int(g_total)
out[line] = {"companies": None, "sum": D(0), "vgo": D(0), "auto": D(0), "manual": D(0),
"goodwill_adjust": ga, "bank": ga, "dev": D(0)}
continue
comps = {}
if line in TOTALS:
for c in COMPANIES:
cl = total_client(form[c], line, TOTALS[line]) if c in form else D(0)
vg = sum((out[s]["companies"][c]["vgo"] for s in TOTALS[line] if out[s]["companies"]), D(0))
comps[c] = {"client": cl, "vgo": vg, "cleaned": cl - vg}
ga = sum((out[s]["goodwill_adjust"] for s in TOTALS[line]), D(0))
au = sum((out[s]["auto"] for s in TOTALS[line]), D(0))
ma = sum((out[s]["manual"] for s in TOTALS[line]), D(0))
dv = None # у итоговых строк расхождений нет
else:
ga = D(0)
au = ma = D(0)
for c in COMPANIES:
cl = client(form, c, line) if c in form else D(0)
sign = -1 if line in EXPENSE else 1
vg, a_, m_ = split_vgo(auto.get((c, line), D(0)), manual.get((c, line), D(0)), sign)
comps[c] = {"client": cl, "vgo": vg, "cleaned": cl - vg}
au += a_
ma += m_
dv = k_int(sum((v for (c, l), v in dev.items() if l == line), D(0)))
dv = D(0) if stype == "income" else dv
s = sum((x["client"] for x in comps.values()), D(0))
vgo = sum((x["vgo"] for x in comps.values()), D(0))
bank = s - vgo + ga
for x in comps.values():
x["share"] = share(x["cleaned"], bank)
out[line] = {"companies": comps, "sum": s, "vgo": vgo, "auto": au, "manual": ma,
"goodwill_adjust": ga, "bank": bank, "dev": dv}
return out
bb = build(BB, BB_LINES, "balance")
ofr = build(OFR, OFR_LINES, "income")
# Расхождения: только строки бланка, не итоги
dev_bb = sum((v["dev"] for k_, v in bb.items() if k_ not in TOTALS), D(0))
na, vb = bb["1300"]["bank"], bb["1600"]["bank"]
if na >= vb * D("0.10"):
base_bb, lim_pct_bb, base_kind_bb = na, D("10"), "NET_ASSETS"
else:
base_bb, lim_pct_bb, base_kind_bb = vb, D("1"), "BALANCE_TOTAL"
lim_bb = r2(base_bb * lim_pct_bb / 100)
rev, sp = ofr["2110"]["bank"], ofr["2200"]["bank"]
dev_ofr = D(0)
if rev != 0 and sp >= 0 and sp / rev >= D("0.10"):
base_ofr, lim_pct_ofr, base_kind_ofr = sp, D("10"), "SALES_PROFIT"
else:
base_ofr, lim_pct_ofr, base_kind_ofr = rev, D("1"), "REVENUE"
lim_ofr = r2(base_ofr * lim_pct_ofr / 100)
summary = {
"balance": {"deviation_total": dev_bb, "base_kind": base_kind_bb, "base_amount": base_bb,
"limit_percent": lim_pct_bb, "limit_amount": lim_bb,
"deviation_percent": r2(dev_bb / base_bb * 100) if base_bb else None,
"exceeded": dev_bb > lim_bb},
"income": {"deviation_total": dev_ofr, "base_kind": base_kind_ofr, "base_amount": base_ofr,
"limit_percent": lim_pct_ofr, "limit_amount": lim_ofr,
"deviation_percent": r2(dev_ofr / base_ofr * 100) if base_ofr else None,
"exceeded": dev_ofr > lim_ofr},
}
# Проверки (Review Focus)
assert bb["1600"]["bank"] == bb["1700"]["bank"], (bb["1600"]["bank"], bb["1700"]["bank"])
assert ofr["2100"]["vgo"] == 0, ofr["2100"]["vgo"]
assert ofr["2100"]["bank"] == ofr["2100"]["sum"]
# агрегат с корректировкой: B→A правило 6 — только 10 000 тыс., автосуммы нет
assert bb["1230"]["companies"]["B"]["vgo"] == D("10000")
# каскад правила 2 (A→C): Σ у «Гаммы» по 1310/1350/1370 = 15 000 = исключению из 1170 по этой паре
c_r2 = sum((amt for c, l, amt, _ in log if c == "C" and l in ("1310", "1350", "1370")), D(0))
assert c_r2 == D("15000000.00"), c_r2
assert k_int(excl[("C", "1370")]) == D("2000")
# меньший итог сторон корректировки A→C: 25 000 vs 22 500 → 22 500 с каждой стороны
a_from_ac = sum((amt for c, l, amt, kind in log if c == "A" and kind == "manual" and l in ("1230", "1170")), D(0))
assert a_from_ac == D("22500000.00"), a_from_ac
if goodwill_enabled:
assert bb["GOODWILL"]["bank"] == D("18000")
assert bb["1100"]["goodwill_adjust"] == D("18000") and bb["1600"]["goodwill_adjust"] == D("18000")
else:
assert bb["GOODWILL"]["bank"] == 0
return bb, ofr, summary, g_total
def show(title, rows, lines):
print(f"\n## {title}")
print("| Строка | " + " | ".join(f"{c}: клиент / взаимные / очищ. / доля" for c in COMPANIES)
+ " | Σ клиент | взаимные | гудвилл | КФО банка | расхожд. |")
for line in lines:
r = rows[line]
cells = []
for c in COMPANIES:
x = (r["companies"] or {}).get(c)
cells.append("—" if x is None else f"{x['client']} / {x['vgo']} / {x['cleaned']} / {x['share']}")
print(f"| {line} | " + " | ".join(cells)
+ f" | {r['sum']} | {r['vgo']} | {r['goodwill_adjust']} | {r['bank']} | {r['dev']} |")
def self_test():
"""Проверки правил, которые сквозной пример не задевает (ревью ветки I-2, I-3, I-4, I-6)."""
# I-2: auto и manual — из одной округлённой ячейки: manual округляется, auto = vgo − manual
assert split_vgo(D("500.50"), D("500.50"), 1) == (D("1"), D("0"), D("1"))
assert split_vgo(D("0"), D("700.00"), -1) == (D("-1"), D("0"), D("-1"))
# I-3: автосумма min_or_full при отрицательной стороне 2 — как при пустой; при amount_1 ≤ 0 — 0
assert auto_amount("min_or_full", D("100"), D("-5")) == D("100")
assert auto_amount("min_or_full", D("-1"), D("50")) == D("0")
# I-3: расхождение агрегата min — и у одностороннего (|a1 − a2|), у min_or_full — нет
assert agg_deviation("min", D("1000"), D("0")) == D("1000")
assert agg_deviation("min", D("100"), D("-50")) == D("150")
assert agg_deviation("min_or_full", D("1000"), D("0")) == D("0")
# I-4: доля по модулю ≥ 1000 % — null
assert share(D("280000"), D("1")) is None
assert share(D("400000"), D("568000")) == D("70.42")
# I-6: итога нет в форме (упрощённая) — client итога = Σ client слагаемых
assert total_client({"1170": D("10"), "1150": D("5")}, "1100", ["1170", "1150"]) == D("15")
assert total_client({"1100": D("20"), "1170": D("10")}, "1100", ["1170"]) == D("20")
if __name__ == "__main__":
self_test()
res = {}
for enabled in (True, False):
bb, ofr, summary, g = compute(enabled)
tag = "гудвилл включён" if enabled else "гудвилл выключен"
show(f"ББ, {tag}", bb, BB_LINES)
show(f"ОФР, {tag}", ofr, OFR_LINES)
print("\nСводка:", json.dumps(summary, default=str, ensure_ascii=False))
res["enabled" if enabled else "disabled"] = {"balance": bb, "income": ofr, "summary": summary}
if "--json" in sys.argv:
with open(sys.argv[sys.argv.index("--json") + 1], "w") as f:
json.dump(res, f, default=str, ensure_ascii=False, indent=1)
print("\nOK: все проверки прошли")