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