"""Генерирует 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")