diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 994f12a..a8bd42b 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -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 | — | diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 1ae2dbc..35c42cc 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -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` | diff --git a/dev/api-contract-corrections.openapi.yaml b/dev/api-contract-corrections.openapi.yaml index d826fb7..997632f 100644 --- a/dev/api-contract-corrections.openapi.yaml +++ b/dev/api-contract-corrections.openapi.yaml @@ -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: Состав или суммы изменились после применённой корректировки } diff --git a/dev/api-contract-kfo.md b/dev/api-contract-kfo.md index d7e0c01..1a75aa7 100644 --- a/dev/api-contract-kfo.md +++ b/dev/api-contract-kfo.md @@ -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 (КФО при неполном +комплекте). diff --git a/dev/api-contract-kfo.openapi.yaml b/dev/api-contract-kfo.openapi.yaml index dc893d9..bb964d5 100644 --- a/dev/api-contract-kfo.openapi.yaml +++ b/dev/api-contract-kfo.openapi.yaml @@ -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: diff --git a/dev/frontend-api-guide.md b/dev/frontend-api-guide.md index af9bb24..3cb9016 100644 --- a/dev/frontend-api-guide.md +++ b/dev/frontend-api-guide.md @@ -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, diff --git a/model/data-model-v5.26.dbml b/model/data-model-v5.26.dbml index b36c5f1..96cbd50 100644 --- a/model/data-model-v5.26.dbml +++ b/model/data-model-v5.26.dbml @@ -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 «расшифровка в разрезе контрагентов»)' } diff --git a/openspec/changes/kfo-calculation-contract/design.md b/openspec/changes/kfo-calculation-contract/design.md index 2ed655d..c30f77c 100644 --- a/openspec/changes/kfo-calculation-contract/design.md +++ b/openspec/changes/kfo-calculation-contract/design.md @@ -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: аналитик видит её в таблице корректировок, а КФО игнорирует | Лишнее предупреждение, если аналитик ввёл её намеренно | diff --git a/scripts/kfo_contract/gen_kfo_openapi.py b/scripts/kfo_contract/gen_kfo_openapi.py new file mode 100644 index 0000000..fb7ed18 --- /dev/null +++ b/scripts/kfo_contract/gen_kfo_openapi.py @@ -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 +""" +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") diff --git a/scripts/kfo_contract/kfo_example.py b/scripts/kfo_contract/kfo_example.py new file mode 100644 index 0000000..d4b10ba --- /dev/null +++ b/scripts/kfo_contract/kfo_example.py @@ -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: все проверки прошли")