Files
mini-openclaw/backend/api/collateral_checks.py
T

118 lines
8.1 KiB
Python
Raw Normal View History

"""Deterministic evidence checks and conservative recovery scenarios."""
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
KINDS = {"asset": "租赁物", "insurance": "保险", "deposit": "保证金", "guarantee": "保证", "mortgage": "抵押", "pledge": "质押"}
def money(value):
return Decimal(str(value or 0))
def number(value):
return str(value.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP))
def calculate(case, rules, haircut, today=None):
today = today or date.today()
checks = []
assets = {}
deposits = {}
identifiers = {m["resource_key"] for m in case["measures"] if m["kind"] == "asset" and m["resource_key"]}
def add(key, title, status, detail, measure=None):
checks.append({"id": f"{measure['id'] if measure else 'project'}:{key}", "measure_id": measure["id"] if measure else None,
"title": title, "status": status, "detail": detail, "evidence": measure.get("evidence") if measure else None})
for kind in rules["required_kinds"]:
if not any(m["kind"] == kind for m in case["measures"]):
add("required-"+kind, "缺少"+KINDS[kind], "missing", "尚未接入,不代表不存在风险")
duplicate_keys = {}
for m in case["measures"]:
if m["resource_key"] and m["kind"] != "insurance":
duplicate_keys.setdefault(m["resource_key"], []).append(m["id"])
for key, ids in duplicate_keys.items():
if len(ids) > 1:
add("duplicate-"+key, "同一风险来源存在重复记录", "review", "关联标识 "+key+";重复来源不叠加计入可回收金额")
for m in case["measures"]:
start = len(checks)
label = m["name"]
if not m.get("confirmed_by"):
add("confirmed", label+":事实待人工确认", "review", "录入或提取结果尚未经审核员核对", m)
evidence = m.get("evidence", {})
if not evidence.get("reference") and not evidence.get("document_id"):
add("evidence", label+":来源缺失", "missing", "需要原始文件页码或 ERP 材料编号", m)
as_of = evidence.get("as_of")
if not as_of:
add("date", label+":证据日期缺失", "missing", "无法判断数据新鲜度", m)
else:
delta = (today-date.fromisoformat(as_of)).days
if delta < 0 or delta > rules["max_age_days"]:
add("date", label+":证据日期需复核", "review", f"证据相差 {delta} 天;允许 {rules['max_age_days']} 天", m)
if m.get("amount") is None:
add("amount", label+":金额缺失", "missing", "金额统一采用人民币万元", m)
if not m["resource_key"]:
add("identity", label+":唯一标识缺失", "missing", "补充序列号、登记号、保单号或账户标识", m)
kind = m["kind"]
bools = {"asset": ["ownership_clear", "enforceable"], "mortgage": ["ownership_clear", "registered", "enforceable"],
"pledge": ["ownership_clear", "registered", "enforceable"], "deposit": ["paid", "enforceable"], "guarantee": ["enforceable"], "insurance": []}[kind]
names = {"ownership_clear": "权属及权利限制", "registered": "登记状态", "enforceable": "可执行性", "paid": "实际到账"}
for key in bools:
if m.get(key) is not True:
add(key, label+":"+names[key], "missing" if m.get(key) is None else "fail", "待核实" if m.get(key) is None else "未满足条件", m)
if kind == "asset":
if not m.get("asset_type"):
add("asset_type", label+":租赁物类型缺失", "missing", "需核对适格性", m)
elif not rules["allowed_asset_types"]:
add("asset_type", label+":适格性待复核", "review", "规则尚未配置允许的租赁物类型", m)
elif m["asset_type"] not in rules["allowed_asset_types"]:
add("asset_type", label+":不在允许类型中", "fail", "依据所选规则版本", m)
if kind in ("insurance", "guarantee", "mortgage", "pledge"):
if not m.get("start_date") or not m.get("end_date"):
add("period", label+":有效期间缺失", "missing", "需核对起止日期", m)
elif date.fromisoformat(m["start_date"]) > today or date.fromisoformat(m["end_date"]) < date.fromisoformat(case["lease_end"]):
add("period", label+":期限未覆盖", "fail", "尚未生效或未覆盖方案到期日 "+case["lease_end"], m)
if kind == "insurance":
absent = set(rules["required_coverages"]) - set(m["coverage_types"])
if absent:
add("coverages", label+":缺少必需险种", "fail", "、".join(sorted(absent)), m)
if not rules["beneficiary"]:
add("beneficiary", label+":受益人规则未配置", "review", "需要配置并审核正式受益人要求", m)
elif m.get("beneficiary") != rules["beneficiary"]:
add("beneficiary", label+":受益人不一致", "fail", "应为 "+rules["beneficiary"], m)
if not m["insured_keys"] or not set(m["insured_keys"]).issubset(identifiers):
add("insured", label+":保险标的无法匹配", "review", "核对租赁物唯一标识与保单清单", m)
effective = min(money(m.get("amount")), money(m.get("payout_limit"))) if m.get("payout_limit") is not None else Decimal(0)
if m.get("payout_limit") is None or m.get("deductible") is None:
add("payout", label+":赔偿上限或免赔额缺失", "missing", "不能以保额直接推定有效赔偿", m)
else:
effective = max(Decimal(0), effective-money(m["deductible"]))
ratio = effective / money(case["amount"])
add("insurance_ratio", label+":单张保单名义覆盖", "pass" if ratio >= money(rules["min_insurance_coverage"]) else "fail",
f"(min(保额,赔偿上限)-免赔额)/授信 = {number(ratio)};不与资产回收价值相加", m)
if not m.get("exclusions") or not m.get("clauses_reviewed"):
add("exclusions", label+":除外责任待复核", "review", "需记录除外条款并由审核员确认其适用性", m)
if kind == "guarantee":
add("capacity", label+":代偿能力需单独判断", "review", "保证额度不直接计入可回收现金;需结合保证人财务和合同证据", m)
if kind in ("asset", "mortgage", "pledge") and m.get("prior_claims") is None:
add("prior_claims", label+":优先权金额缺失", "missing", "未查明优先受偿权时不计入回收价值", m)
issues = [x for x in checks[start:] if x["status"] != "pass"]
if not issues:
add("ready", label+":已配置检查项满足", "pass", "仅表示所选规则及已确认材料检查结果,不构成投放许可", m)
key = m["resource_key"]
if len(duplicate_keys.get(key, [])) <= 1:
if kind in ("asset", "mortgage", "pledge"):
assets[key] = max(Decimal(0), money(m["amount"]) * (1-money(haircut)/100)-money(m["prior_claims"]))
elif kind == "deposit":
deposits[key] = money(m["amount"])
recovery = sum(assets.values(), Decimal(0)) + sum(deposits.values(), Decimal(0))
exposure = money(case["amount"])
ratio = recovery / exposure
add("coverage", "折价后直接回收覆盖", "pass" if ratio >= money(rules["min_asset_coverage"]) else "fail",
f"(已确认资产折价净值+已确认到账保证金)/授信 = {number(ratio)};要求 {rules['min_asset_coverage']}")
return {"checks": checks, "scenario": {"haircut": str(haircut), "asset_recovery": number(sum(assets.values(), Decimal(0))),
"deposit_recovery": number(sum(deposits.values(), Decimal(0))), "total_recovery": number(recovery),
"coverage": number(ratio), "uncovered": number(max(Decimal(0), exposure-recovery)),
"formula": "sum(max(0,资产价值*(1-折价率)-优先权金额))+已到账保证金;重复来源排除;保险及保证不叠加"}}