170 lines
9.2 KiB
Python
170 lines
9.2 KiB
Python
"""Decision-record prototype: shared, persisted project state; no external model calls."""
|
||
import json
|
||
import sqlite3
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
|
||
router = APIRouter(prefix="/api/xingbang/workspace", tags=["兴邦方案工作台(演示)"])
|
||
DB_PATH = Path(__file__).resolve().parents[1] / "xingbang_data" / "workspace.sqlite3"
|
||
|
||
|
||
def now():
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def connect():
|
||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
db = sqlite3.connect(DB_PATH)
|
||
db.execute("CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, body TEXT NOT NULL)")
|
||
return db
|
||
|
||
|
||
def get(db, project_id):
|
||
row = db.execute("SELECT body FROM projects WHERE id=?", (project_id,)).fetchone()
|
||
if not row:
|
||
raise HTTPException(404, "项目不存在")
|
||
return json.loads(row[0])
|
||
|
||
|
||
def save(db, project):
|
||
db.execute("INSERT OR REPLACE INTO projects VALUES (?,?)", (project["id"], json.dumps(project, ensure_ascii=False)))
|
||
|
||
|
||
class ProjectInput(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
client: str = Field(min_length=1, max_length=120)
|
||
amount: float = Field(gt=0, allow_inf_nan=False)
|
||
assets: float | None = Field(default=None, gt=0, allow_inf_nan=False)
|
||
liabilities: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||
lease_value: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||
insurance: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||
|
||
|
||
class Action(BaseModel):
|
||
kind: str
|
||
target: str = ""
|
||
note: str = Field(default="", max_length=4000)
|
||
actor: str = Field(min_length=1, max_length=80)
|
||
amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
|
||
|
||
|
||
def assess(project):
|
||
snapshot = project["snapshots"][-1]
|
||
data = snapshot["data"]
|
||
items = []
|
||
rules = [
|
||
("debt", "资产负债率", ["liabilities", "assets"], "负债总额 / 资产总额", 0.7, "gt"),
|
||
("lease", "租赁物价值覆盖", ["lease_value", "amount"], "租赁物价值 / 授信金额", 1.2, "lt"),
|
||
("insurance", "保额覆盖", ["insurance", "amount"], "保额 / 授信金额", 1, "lt"),
|
||
]
|
||
for key, title, fields, formula, threshold, op in rules:
|
||
missing = [f for f in fields if data.get(f) is None]
|
||
value = None if missing else round(data[fields[0]] / data[fields[1]], 6)
|
||
triggered = value is not None and (value > threshold if op == "gt" else value < threshold)
|
||
items.append({"id": key, "title": title, "value": value,
|
||
"category": "信息缺失" if missing else "待核查疑点" if triggered else "指标未触发(非无风险)",
|
||
"status": "待复核", "note": "", "evidence": {"snapshot": snapshot["id"],
|
||
"source": snapshot["source"], "time": snapshot["time"], "fields": {f: data.get(f) for f in fields},
|
||
"formula": formula, "rule_version": "demo-1", "threshold": threshold,
|
||
"operator": op, "basis": "演示阈值,非兴邦正式制度"}})
|
||
project["risks"] = items
|
||
project["assessment_snapshot"] = snapshot["id"]
|
||
project["stale"] = False
|
||
return items
|
||
|
||
|
||
@router.get("/projects")
|
||
def projects():
|
||
with connect() as db:
|
||
return [json.loads(row[0]) for row in db.execute("SELECT body FROM projects ORDER BY rowid DESC")]
|
||
|
||
|
||
@router.post("/projects")
|
||
def create(data: ProjectInput):
|
||
project = {"id": uuid4().hex, "name": data.name, "client": data.client, "mode": "演示 / 人工录入",
|
||
"snapshots": [], "risks": [], "tasks": [], "reports": [], "cards": [], "audit": [], "stale": True}
|
||
project["snapshots"].append({"id": uuid4().hex, "time": now(), "source": "人工录入(未接入ERP)",
|
||
"template_version": "manual-1", "mapping_version": "manual-1",
|
||
"unit": "万元", "data": data.model_dump()})
|
||
with connect() as db:
|
||
save(db, project)
|
||
return project
|
||
|
||
|
||
@router.post("/projects/{project_id}/actions")
|
||
def action(project_id: str, request: Action):
|
||
with connect() as db:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
p = get(db, project_id)
|
||
k = request.kind
|
||
if k == "assess":
|
||
if any(r["status"] != "待复核" for r in p["risks"]) and not p["stale"]:
|
||
raise HTTPException(409, "已有人工判断;请保留当前结果,数据变更后再重新研判")
|
||
previous = p["risks"]
|
||
assess(p)
|
||
p["comparison"] = [{"title": r["title"], "before": next((x["category"] for x in previous if x["id"] == r["id"]), "首次研判"), "after": r["category"]} for r in p["risks"]]
|
||
elif k == "change":
|
||
if request.amount is None or not request.note.strip():
|
||
raise HTTPException(422, "请填写新额度和变更原因")
|
||
data = dict(p["snapshots"][-1]["data"], amount=request.amount)
|
||
p["snapshots"].append(dict(p["snapshots"][-1], id=uuid4().hex, time=now(), data=data))
|
||
p["stale"] = True
|
||
p["impacts"] = ["租赁物价值覆盖率", "保额覆盖率", "担保与保证金条件(待人工复核)", "报告授信方案与风险章节"]
|
||
for report in p["reports"]:
|
||
report["stale"] = True
|
||
elif k in ("confirm", "reject", "task"):
|
||
if p["stale"]:
|
||
raise HTTPException(409, "请先对当前快照重新研判")
|
||
risk = next((r for r in p["risks"] if r["id"] == request.target), None)
|
||
if not risk or not request.note.strip():
|
||
raise HTTPException(422, "请选择风险项并填写判断依据或补件要求")
|
||
if k == "task":
|
||
p["tasks"].append({"id": uuid4().hex, "risk": risk["title"], "owner": request.actor,
|
||
"requirement": request.note, "status": "待提交", "snapshot": p["snapshots"][-1]["id"]})
|
||
else:
|
||
risk.update(status="人工确认" if k == "confirm" else "人工驳回", note=request.note, actor=request.actor)
|
||
elif k in ("submit_task", "close_task"):
|
||
task = next((t for t in p["tasks"] if t["id"] == request.target), None)
|
||
if not task or not request.note.strip():
|
||
raise HTTPException(422, "请提供任务及处理说明")
|
||
expected = "待提交" if k == "submit_task" else "已提交待确认"
|
||
if task["status"] != expected:
|
||
raise HTTPException(409, "任务状态不允许此操作")
|
||
task.update(status="已提交待确认" if k == "submit_task" else "已解决", note=request.note, actor=request.actor)
|
||
elif k == "report":
|
||
if p["stale"] or not p["risks"] or any(r["status"] == "待复核" for r in p["risks"]):
|
||
raise HTTPException(409, "请先完成当前快照的研判和逐项人工复核")
|
||
snapshot = p["snapshots"][-1]
|
||
body = f"# {p['name']} 评审材料草稿\n\n客户:{p['client']}\n\n授信金额:{snapshot['data']['amount']} 万元\n\n数据来源:人工录入;快照:{snapshot['id']}\n\n"
|
||
body += "\n\n".join(f"## {r['title']}\n{r['category']} / {r['status']}\n\n判断:{r['note']}\n\n证据:{json.dumps(r['evidence'], ensure_ascii=False)}" for r in p["risks"])
|
||
body += "\n\n审批结果、法律有效性、附件原文及租后表现:待补充。本文使用演示规则,不能作为正式审批依据。"
|
||
p["reports"].append({"id": uuid4().hex, "body": body, "snapshot": snapshot["id"], "stale": False, "status": "草稿"})
|
||
elif k == "finalize":
|
||
report = next((r for r in p["reports"] if r["id"] == request.target), None)
|
||
if not report or report["stale"] or report["status"] != "草稿" or not request.note.strip():
|
||
raise HTTPException(409, "仅当前版本草稿可定稿,且需填写确认意见")
|
||
report.update(status="已定稿", actor=request.actor, note=request.note)
|
||
p["cards"].append({"id": uuid4().hex, "status": "草稿", "report": report["id"], "snapshot": report["snapshot"],
|
||
"facts": json.loads(json.dumps(p["risks"])), "history": [], "outcome": ""})
|
||
elif k == "card":
|
||
card = next((c for c in p["cards"] if c["id"] == request.target), None)
|
||
if not card or not request.note.strip():
|
||
raise HTTPException(422, "请选择经验卡并填写审核依据或租后结果")
|
||
states = ["草稿", "主审员已确认", "部门审核", "可检索", "已验证", "已过期/归档"]
|
||
index = states.index(card["status"])
|
||
if index == len(states) - 1:
|
||
raise HTTPException(409, "经验卡已归档")
|
||
card["status"] = states[index + 1]
|
||
card["history"].append({"status": card["status"], "actor": request.actor, "note": request.note, "time": now()})
|
||
if card["status"] == "已验证":
|
||
card["outcome"] = request.note
|
||
else:
|
||
raise HTTPException(422, "不支持的操作")
|
||
p["audit"].append({"time": now(), **request.model_dump()})
|
||
save(db, p)
|
||
return p
|