arm-ka-docs/scripts/kfo_contract/gen_kfo_openapi.py

286 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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