Files

118 lines
8.1 KiB
Python
Raw Permalink 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.
"""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-折价率)-优先权金额))+已到账保证金;重复来源排除;保险及保证不叠加"}}