КФО: правки финального ревью — порядок каскада, округление auto/manual, односторонние агрегаты, доля, итоги, OQ-194; генератор контракта в scripts/ (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-06 20:43:26 +04:00
parent 9ee0796314
commit 232f82cfce
10 changed files with 687 additions and 36 deletions

View File

@ -1009,10 +1009,12 @@ OQ — менять по ответу.
«тыс. руб.» — как есть, «руб.» — / 1000, «млн руб.» — × 1000, до двух знаков `ROUND_HALF_UP`.
- **Знак строк расходов.** Формы хранят расходы по-разному (§7, §11.9: скобки, минус или «без знака»). Строки
расходов бланка — **2120, 2210, 2220, 2330, 2350** — приводятся к одной конвенции: `client_amount = −|значение|`.
Остальные строки — как в форме (1370, 2300, 2400 и прочие бывают любого знака по существу).
Остальные строки — как в форме (1370, 2300, 2400 и прочие бывают любого знака по существу). 2410 / 2411 (налог
на прибыль) в §7 стоят в `abs_terms`, но их знак не определён (OQ-12) — берутся как в форме.
- Нет формы → значения компании 0, `FORM_MISSING`; две и более подтверждённые формы одного типа → 0,
`FORM_AMBIGUOUS`; единицы не распознаны → 0, `FORM_UNITS_UNKNOWN`. Значение из ОСВ вместо формы — OQ-182.
- КФО считается и при неполном комплекте — по тому, что подтверждено и привязано (DOC-274636, допущение 17).
- КФО считается и при неполном комплекте — по тому, что подтверждено и привязано, с `FORM_MISSING` (DOC-274636,
допущение 17; OQ-194).
3. **Агрегаты.** Взаимные суммы КФО берёт из **агрегатов таблицы корректировок** (§5 п. 2a, `VgoService`): тройка
(правило, компания 1, компания 2) с суммами сторон `amount_1`, `amount_2` (рубли) после ручных решений
`vgo_assignment`. Отдельного обхода ОСВ в КФО нет: что аналитик видит в таблице корректировок, то и вход КФО.
@ -1024,14 +1026,16 @@ OQ — менять по ответу.
| Режим | Автосумма | Куда |
|---|---|---|
| `min` (ББ 3–8) | `min(amount_1, amount_2)`, если обе стороны > 0 и отклонение в допуске (порог — блок «Корректировка», 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) | `article_1_line` компании 1; компании 2 — каскадом по `article_2_line` |
| `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»). Агрегаты обходятся в порядке `rule_no`, затем ИНН компании 1, затем ИНН компании 2.
исключается из строки 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`,
@ -1052,28 +1056,36 @@ OQ — менять по ответу.
приближает значение к нулю (при превышении — проходит через ноль). Округление ячейки, а не каждой тройки и не «без
округления» — OQ-184.
7. **Строка КФО.** По компании: `client_amount`, `vgo_amount`, `cleaned_amount`, `share_percent = cleaned / bank_amount
× 100` (хранится 4 знака, отдаётся 2; `bank_amount = 0` → `null`; бывает отрицательной и больше 100 %). По строке:
`sum_amount = Σ client`, `vgo_amount = Σ vgo по компаниям = eliminated_auto + eliminated_manual` (auto — от агрегатов
без корректировки и гудвилл-пар, manual — от агрегатов с корректировкой; по одному агрегату они **не складываются**),
× 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): «по данным клиента» —
итог из формы компании; «в т. ч. взаимные» — Σ `vgo_amount` слагаемых по формуле бланка с их знаками (1100 ←
1110…1190; 1600 ← 1100 + 1200; 1700 ← 1300 + 1400 + 1500; 2100 ← 2110 + 2120; 2200 ← 2100 + 2210 + 2220; дальше по
бланку); `goodwill_adjust` — Σ слагаемых (то есть G у 1100 и 1600); очищенные — разность. Итог КФО — подписанный
итог из формы компании, а если строки итога в форме нет (упрощённая форма, `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 =
ДО), если он есть, **не применяется** — ни автосумма, ни корректировка. Строка 58.01 пары перенесена аналитиком в
ДО), если он есть, **не применяется** — ни автосумма, ни корректировка (применённая корректировка такого агрегата
в сводку корректировок п. 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|` — на статью стороны с
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` — Σ по строкам бланка своего отчёта, без итогов;
@ -1083,13 +1095,15 @@ OQ — менять по ответу.
числе ЧА ≤ 0) → база ВБ, предел 1 % ВБ.
- ОФР: `2200 ≥ 0` и `2200 / 2110 ≥ 10 %` → база 2200, предел 10 % 2200; иначе → база 2110, предел 1 % 2110.
`2110 = 0` → база 2110, предел 0 (OQ-188).
- Превышение — `total_deviation > предел`; равно — норма. Процент от базы — для сводки, база = 0 → `null`.
- Превышение — `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`, тыс. руб.), причины отклонений от Методики
корректировки — число агрегатов с применённой корректировкой и Σ их исключений по отчёту, обеих сторон
(`kfo_result.corrections_count_bb/_ofr`; `corrections_amount_bb/_ofr` = Σ `eliminated_manual` строк бланка без
итогов, тыс. руб.), причины отклонений от Методики
(`consolidation_version.methodology_deviations` — ввод на вкладке «КФО»), решение УОБ (номер и дата — только
чтение). «Подход по Методике» и «согласовано клиентом» — не делаем до ответа заказчика (OQ-190, OQ-191).
12. **Пересчёт и заморозка.** Результат хранится у версии (`kfo_result`, одна запись). Пока версия не заморожена,
@ -1100,14 +1114,15 @@ OQ — менять по ответу.
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`. Правка ручных полей — аудит `KFO_EDIT`.
`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 | отклонение 1,99 % — автосумма 100 000 400,50: А 1230, Б 1520 |
| пр. 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; агрегат не применяется |
@ -1127,6 +1142,7 @@ OQ — менять по ответу.
| 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 | — |

View File

@ -197,3 +197,4 @@
| 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

@ -282,7 +282,7 @@ components:
deviation_percent: { $ref: '#/components/schemas/Percent' }
exceeds_threshold:
type: boolean
description: deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой
description: deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой; у правила min_or_full — всегда false
status: { type: string, enum: [AUTO, DISCREPANCY, ONE_SIDED, MANUAL] }
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: Состав или суммы изменились после применённой корректировки }

View File

@ -2,7 +2,8 @@
Контракт вкладки «КФО» отчёта версии консолидации — **до реализации** (docs #206, OpenSpec change
`kfo-calculation-contract`). Машиночитаемая копия — `dev/api-contract-kfo.openapi.yaml` с тремя полными примерами
ответа (их можно брать как фейк для экрана). Когда расчёт появится в `arm-core`, копия выгружается из `app.openapi()` и
ответа (их можно брать как фейк для экрана); она генерируется `scripts/kfo_contract/gen_kfo_openapi.py` из
сквозного примера §8 (`scripts/kfo_contract/kfo_example.py`, там же проверки правил) — руками не править. Когда расчёт появится в `arm-core`, копия выгружается из `app.openapi()` и
сверяется тестом, как у гудвилла; если файлы разойдутся с кодом — прав код, оба файла правятся вслед за ним.
Источники, в порядке приоритета:
@ -37,7 +38,8 @@
включая нулевые; `is_total` — итоговая строка.
- **По компании** (`companies[]` строки, порядок — как `KfoResultOut.companies`): «По данным клиента»
(`client_amount`), «В том числе взаимные обязательства / обороты» (`vgo_amount`), «Очищенные показатели»
(`cleaned_amount = client_amount − vgo_amount`), «Доля в показателе по группе, %» (`share_percent`). Строки расходов
(`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`** — «КФО, подготовленная банком»
@ -53,7 +55,8 @@
чтение), корректировки — число агрегатов с применённой корректировкой и Σ их исключений.
- **Предупреждения** (`warnings[]`, п. 12) — не блокируют: `FORM_MISSING`, `FORM_AMBIGUOUS`, `FORM_UNITS_UNKNOWN`,
`LINE_OUTSIDE_BLANK`, `UNALLOCATED_ROWS` (`count` — строк), `INCOME_RULES_MISSING`, `GOODWILL_NOT_APPLIED`,
`GOODWILL_ROW_REASSIGNED`. Тексты `message` — с сервера, фронт показывает как есть.
`GOODWILL_ROW_REASSIGNED`, `CORRECTION_SUPERSEDED_BY_GOODWILL` (корректировка агрегата правила 2 не применена —
пару закрыл гудвилл). Тексты `message` — с сервера, фронт показывает как есть.
## 3. Эндпоинты
@ -174,4 +177,5 @@ KfoAnnotationsIn {comments?: [{statement_type, line_code, comment|null}], thre
Варианты, принятые на старте, могут измениться по ответу заказчика — контракт меняется вместе с §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-190/191 (подход по Методике, согласование клиентом — полей нет), OQ-192 (пересчёт), OQ-194 (КФО при неполном
комплекте).

View File

@ -1,4 +1,5 @@
# Сгенерировано из сквозного примера §8 «КФО» (docs #206); правится вместе с dev/api-contract-kfo.md.
# Сгенерировано scripts/kfo_contract/gen_kfo_openapi.py из сквозного примера §8 «КФО» (docs #206).
# Руками не править: правка генератора (и kfo_example.py) → перегенерация; текст — dev/api-contract-kfo.md.
openapi: 3.1.0
info:
title: АРМ КА — результат КФО (AURORA-1546/1547)
@ -55,7 +56,7 @@ paths:
related_group_name: ГСК «Альфа»
reporting_date: '2025-10-01'
version_no: 1
version_status: IN_PROGRESS
version_status: CONSOLIDATION_RF
scenario: CREATE
approach: BASIC
methodology_deviations: null
@ -717,7 +718,7 @@ paths:
related_group_name: ГСК «Альфа»
reporting_date: '2025-10-01'
version_no: 1
version_status: IN_PROGRESS
version_status: CONSOLIDATION_RF
scenario: CREATE
approach: BASIC
methodology_deviations: null
@ -2115,11 +2116,12 @@ components:
examples: *id002
description: «Очищенные показатели» = client_amount − vgo_amount
share_percent:
type: &id004
type:
- string
- 'null'
pattern: ^-?\d{1,5}\.\d{2}$
description: «Доля в показателе по группе, %» от bank_amount строки; null при нулевом итоге
pattern: ^-?\d{1,3}\.\d{2}$
description: «Доля в показателе по группе, %» от bank_amount строки, ROUND_HALF_UP; null при нулевом итоге или |доля|
≥ 1000 %
required:
- legal_entity_id
- client_amount
@ -2230,7 +2232,9 @@ components:
- '1.00'
limit_amount: *id003
deviation_percent:
type: *id004
type:
- string
- 'null'
pattern: ^-?\d{1,5}\.\d{2}$
description: deviation_total / base_amount × 100; null при нулевой базе
exceeded:
@ -2299,7 +2303,20 @@ components:
type: integer
version_status:
type: string
description: consolidation_version.status
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:
@ -2366,6 +2383,7 @@ components:
- INCOME_RULES_MISSING
- GOODWILL_NOT_APPLIED
- GOODWILL_ROW_REASSIGNED
- CORRECTION_SUPERSEDED_BY_GOODWILL
message:
type: string
legal_entity_id:

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[],
osv_entry_ids_b[]}}`, порядок — `rule_no`, затем порядок компаний периметра. `status`: `AUTO` — элиминируется
`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 (по ним — переход в сводную ОСВ).
`EliminationPairsOut` — `{version_no, rule_id, rule_no, company_a_id, company_b_id, amount_a, amount_b, deviation_amount,

View File

@ -1404,10 +1404,10 @@ Table kfo_result {
threshold_justification text [note: 'обоснование принятия риска при превышении']
goodwill_total numeric(18,2) [note: 'v5.26: Σ G применимых пар гудвилла, тыс. руб. = bank_amount строки GOODWILL (§8 «КФО» п. 8)']
has_deviations boolean
warnings jsonb [note: 'v5.26: предупреждения расчёта [{code, message, legal_entity_id?, statement_type?, count?}] (§8 «КФО» п. 12)']
warnings jsonb [note: 'v5.26: предупреждения расчёта [{code, message, legal_entity_id?, statement_type?, count?}], коды — §8 «КФО» п. 12']
corrections_count_bb int [note: 'v5.26: число агрегатов ББ с применённой корректировкой (§8 «КФО» п. 11)']
corrections_count_ofr int
corrections_amount_bb numeric(18,2) [note: 'v5.26: Σ исключений этих корректировок, тыс. руб.']
corrections_amount_bb numeric(18,2) [note: 'v5.26: Σ исключений этих корректировок обеих сторон = Σ eliminated_manual строк бланка ББ без итогов, тыс. руб.']
corrections_amount_ofr numeric(18,2)
Note: 'Шапка результата КФО + контрольные пороги. Все суммы — тыс. руб. (§8 «КФО»); одна запись на версию, пересчитывается при чтении, пока не is_frozen'
}
@ -1447,7 +1447,7 @@ Table kfo_result_line_company {
client_amount numeric(18,2) [note: 'v5.26: «По данным клиента», тыс. руб.; строки расходов приведены к −|x| (§8 «КФО» п. 2)']
vgo_amount numeric(18,2) [note: 'v5.26: «В т. ч. взаимные», тыс. руб. до целых, со знаком строки (§8 «КФО» п. 6)']
amount numeric(18,2) [note: '«Очищенные показатели» = client_amount − vgo_amount']
share_percent numeric(7,4) [note: 'v5.1 макет: «Доля в показателе по группе, %» = amount / kfo_result_line.consolidated_amount × 100; null при нулевом итоге']
share_percent numeric(7,4) [note: 'v5.1 макет: «Доля в показателе по группе, %» = amount / kfo_result_line.consolidated_amount × 100, ROUND_HALF_UP; null при нулевом итоге или |доля| ≥ 1000 % (иначе переполнение numeric(7,4))']
Indexes { (kfo_result_line_id, legal_entity_id) [pk] }
Note: 'Разрез по компаниям для расшифровки статьи (ЕХ 6.6 «расшифровка в разрезе контрагентов»)'
}

View File

@ -196,7 +196,7 @@ manual — от агрегатов с корректировкой; гудвил
`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`; `consolidated_amount = sum − vgo + goodwill_adjust`; `deviation_amount` —
`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`,
@ -208,7 +208,8 @@ manual — от агрегатов с корректировкой; гудвил
- `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) — в AURORA-1546.
- Миграция `arm-core` (семь колонок nullable — две в `kfo_result_line_company`, пять в `kfo_result` — и значение
`audit_action`) — в AURORA-1546.
Разреза «расхождение по компании» не добавляем: на экране «Расхождения» — общая колонка строки.
@ -286,3 +287,13 @@ AURORA-1547 — `api-contract-kfo.md` и `api-contract-kfo.openapi.yaml`; в о
| 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,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{1,5}\.\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: все проверки прошли")