G1 review fixes P0-1/P0-2/P1-3/P1-4/P1-5/P2-6/P2-7/P2-8 applied: - up() filters comment lines then asserts missing_tables == [] (CREATE hits 6/6) - created counter = new-object diff via information_schema (idempotent second run = 0) - down() drops columns per-column, dumps via write-path creds + fail-closed - seeds expanded to 8 rules + 6 limits; data_status DEFAULT estimated - no physical FK (app-layer checks), amount FLOAT wan-yuan, round(x,4) Scope: 6 treasury_* tables + cash_plans 4 added columns; cash.py untouched. Not pushed: deploy is approval-gated (de851257).
1200 lines
57 KiB
Python
1200 lines
57 KiB
Python
"""司库(资金)体系 · 阶段一 端点层 — /api/cma/treasury/*
|
||
|
||
PRD v1.1 附录 A + 阶段一范围(账户池 / 头寸 / 内控闸门 / 授权矩阵 / 两页面数据面)。
|
||
G1 裁定落地:
|
||
* 不建物理外键 → 应用层校验覆盖(DDL §F-2 逐表说明:账户同实体校验 / AC-001 账外账户 / 预算同实体 …)
|
||
* 金额 FLOAT(万元):写库前 round(x,4),比较一律用容差 1e-4(禁用浮点等值比较)
|
||
* 分级授权「取最严」:amount 档(rules.params.tiers)× treasury_limits(role/account/tier scopes)× 拆单累计(同一提交人×同一收款方×同一自然日)
|
||
* 全量留痕:每条规则每次评估写一行 treasury_control_hits;detail 固定结构化字段
|
||
{rule_code, rule_version, matched_scope, threshold, cumulative, verdict_basis}
|
||
* 豁免通道:指定审批人 + 必填事由 + 全量留痕;豁免级别不得低于该笔所需审批级数
|
||
边界:**零改动 cash.py**;只读复用 cash_plans / budget_plans;不动其他模块表结构。
|
||
"""
|
||
import logging
|
||
from datetime import datetime, date
|
||
from typing import Optional, List, Any, Dict
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.database import get_db
|
||
from app.deps import get_entity_id
|
||
from app.auth_middleware import require_role, require_auth
|
||
from app.models import BudgetPlan, CashPlan, User, OrgNode
|
||
from app.models.treasury import (
|
||
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
||
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
||
)
|
||
|
||
logger = logging.getLogger("cma.treasury")
|
||
|
||
router = APIRouter(
|
||
prefix="/api/cma/treasury",
|
||
tags=["司库(资金)"],
|
||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||
)
|
||
|
||
TOL = 1e-4 # 金额比较容差(G1 §3-3 附条件:禁浮点等值比较)
|
||
ACTIVE_STATUSES = ("pending_approval", "approved", "paid") # NP-001 占用口径:已审批未支付 + 已支付
|
||
INTERNAL_TRANSFER = "内部划转"
|
||
|
||
|
||
def _r(x) -> float:
|
||
"""金额写库/计算统一 round(x, 4)(万元,FLOAT)。"""
|
||
return round(float(x or 0), 4)
|
||
|
||
|
||
def _ge(a, b) -> bool:
|
||
return float(a) >= float(b) - TOL
|
||
|
||
|
||
def _lt(a, b) -> bool:
|
||
return float(a) < float(b) - TOL
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 规则引擎(阶段一:AC-001 / NP-001 / AU-001 / BL-001 / LQ-001)
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _load_rules(db: Session, entity_id: int) -> Dict[str, TreasuryControlRule]:
|
||
rows = db.query(TreasuryControlRule).filter(
|
||
TreasuryControlRule.entity_id == entity_id,
|
||
TreasuryControlRule.enabled == 1,
|
||
).all()
|
||
return {r.rule_code: r for r in rows}
|
||
|
||
|
||
def _hit_detail(rule_code: str, rule_version: str, matched_scope: str,
|
||
threshold: Any, cumulative: Any, verdict_basis: str) -> dict:
|
||
"""[调整2] detail 固定结构化字段(审计追问要能自证)。"""
|
||
return {
|
||
"rule_code": rule_code,
|
||
"rule_version": rule_version,
|
||
"matched_scope": matched_scope,
|
||
"threshold": threshold,
|
||
"cumulative": cumulative,
|
||
"verdict_basis": verdict_basis,
|
||
}
|
||
|
||
|
||
def _record_hits(db: Session, entity_id: int, target_type: str, target_id: int,
|
||
results: List[dict]) -> None:
|
||
"""append-only 留痕:每条规则每次评估一行(pass 也留,审计可复现全链路)。"""
|
||
for res in results:
|
||
db.add(TreasuryControlHit(
|
||
entity_id=entity_id,
|
||
target_type=target_type,
|
||
target_id=target_id,
|
||
rule_code=res["rule_code"],
|
||
verdict=res["verdict"],
|
||
detail=res["detail"],
|
||
))
|
||
|
||
|
||
def _pick_account(db: Session, entity_id: int, account_id: int) -> TreasuryAccount:
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.id == account_id,
|
||
TreasuryAccount.entity_id == entity_id, # 防跨实体误挂(G1 附加验收①)
|
||
).first()
|
||
if not acc:
|
||
raise HTTPException(404, f"账户 {account_id} 不存在或不属于当前账套")
|
||
return acc
|
||
|
||
|
||
def _resolve_submitter_role(db: Session, submitter: str) -> Optional[str]:
|
||
if not submitter:
|
||
return None
|
||
u = db.query(User).filter(or_(User.name == submitter, User.username == submitter)).first()
|
||
return u.role if u else None
|
||
|
||
|
||
def _tier_of(tiers: List[dict], amount: float):
|
||
"""金额段命中(口径2-④ 左闭右开 [from,to)):返回 (required_approvals, scope_text)。"""
|
||
for t in sorted(tiers, key=lambda x: float(x.get("from") or 0)):
|
||
lo = float(t.get("from") or 0)
|
||
hi = t.get("to")
|
||
if _ge(amount, lo) and (hi is None or _lt(amount, float(hi))):
|
||
return int(t.get("required_approvals") or 0), f"[{lo},{hi if hi is not None else '∞'})"
|
||
return 0, "none"
|
||
|
||
|
||
def _eval_ac001(db, entity_id, payment, rule) -> dict:
|
||
"""AC-001 账外账户:付款账户必须已登记且 active(结构性校验,无金额参数)。"""
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.id == payment.from_account_id,
|
||
TreasuryAccount.entity_id == entity_id,
|
||
).first()
|
||
if not acc:
|
||
verdict, basis = "blocked", "account_not_registered(账外账户:未登记入台账)"
|
||
elif acc.status != "active":
|
||
verdict, basis = "blocked", f"account_status={acc.status}(非 active 账户不允许发起支付)"
|
||
else:
|
||
verdict, basis = "pass", f"account#{acc.id} {acc.account_no} status=active"
|
||
return {"rule_code": "AC-001", "verdict": verdict, "severity": "block",
|
||
"detail": _hit_detail("AC-001", "builtin-v1", "account", None, None, basis)}
|
||
|
||
|
||
def _eval_np001(db, entity_id, payment, rule) -> dict:
|
||
"""NP-001 无预算不开支:占用 = 已支付 + 已审批未支付 + 本单;匹配不到走指定/补录预算流程。"""
|
||
params = rule.params or {}
|
||
severity = params.get("severity") or rule.severity
|
||
budget_value, scope = None, None
|
||
if payment.budget_plan_id:
|
||
bp = db.query(BudgetPlan).filter(
|
||
BudgetPlan.id == payment.budget_plan_id, BudgetPlan.entity_id == entity_id).first()
|
||
if bp:
|
||
budget_value, scope = bp.budget_value, f"budget_plan:{bp.id}"
|
||
if budget_value is None and payment.kpi_id:
|
||
period = payment.pay_date.strftime("%Y-%m") if payment.pay_date else datetime.now().strftime("%Y-%m")
|
||
bp = db.query(BudgetPlan).filter(
|
||
BudgetPlan.entity_id == entity_id, BudgetPlan.kpi_id == payment.kpi_id,
|
||
BudgetPlan.period == period).first()
|
||
if bp:
|
||
budget_value, scope = bp.budget_value, f"kpi:{bp.kpi_id}@{period}"
|
||
this_amt = _r(payment.amount)
|
||
if budget_value is None:
|
||
return {"rule_code": "NP-001", "verdict": severity, "severity": severity,
|
||
"detail": _hit_detail("NP-001", rule.rule_version, "none", None, this_amt,
|
||
(params.get("unmatched") or "specify_budget_flow")
|
||
+ "(未匹配到预算:走指定/补录预算流程并留痕,不直接卡死)")}
|
||
q = db.query(func.coalesce(func.sum(TreasuryPayment.amount), 0)).filter(
|
||
TreasuryPayment.entity_id == entity_id,
|
||
TreasuryPayment.status.in_(ACTIVE_STATUSES),
|
||
TreasuryPayment.id != payment.id,
|
||
)
|
||
if payment.budget_plan_id:
|
||
q = q.filter(TreasuryPayment.budget_plan_id == payment.budget_plan_id)
|
||
elif payment.kpi_id:
|
||
q = q.filter(TreasuryPayment.kpi_id == payment.kpi_id)
|
||
prev = float(q.scalar() or 0)
|
||
cumulative = _r(prev + this_amt)
|
||
ok = cumulative <= float(budget_value) + TOL
|
||
verdict = "pass" if ok else severity
|
||
basis = ("占用口径 %s;占用=已支付+已审批未支付+本单" % (params.get("occupancy") or "paid+approved+pending_approval+this"))
|
||
if not ok:
|
||
basis += f";占用 {cumulative} 超过预算 {_r(budget_value)}"
|
||
return {"rule_code": "NP-001", "verdict": verdict, "severity": severity,
|
||
"detail": _hit_detail("NP-001", rule.rule_version, scope or "none",
|
||
_r(budget_value), cumulative, basis)}
|
||
|
||
|
||
def _eval_au001(db, entity_id, payment, rule) -> dict:
|
||
"""AU-001 超授权拦截(拆单累计穿透 + 日累计升级 + 多档取最严 + 左闭右开)。"""
|
||
params = rule.params or {}
|
||
tiers = params.get("tiers") or []
|
||
amount = _r(payment.amount)
|
||
daily_cum_limit = params.get("daily_cum_limit")
|
||
pay_day = payment.pay_date or date.today()
|
||
|
||
base_req, base_scope = _tier_of(tiers, amount)
|
||
|
||
# 拆单累计穿透:同一提交人 × 同一收款方 × 同一自然日(含本单)
|
||
cum_q = db.query(func.coalesce(func.sum(TreasuryPayment.amount), 0)).filter(
|
||
TreasuryPayment.entity_id == entity_id,
|
||
TreasuryPayment.status.in_(ACTIVE_STATUSES),
|
||
TreasuryPayment.id != payment.id,
|
||
TreasuryPayment.submitter == payment.submitter,
|
||
TreasuryPayment.to_name == payment.to_name,
|
||
TreasuryPayment.pay_date == pay_day,
|
||
)
|
||
prev_same = float(cum_q.scalar() or 0)
|
||
cum_same = _r(prev_same + amount)
|
||
cum_req, cum_scope = _tier_of(tiers, cum_same)
|
||
|
||
# 日累计升级:同提交人当日全部(含本单)超 daily_cum_limit → 自主档升一级
|
||
day_q = db.query(func.coalesce(func.sum(TreasuryPayment.amount), 0)).filter(
|
||
TreasuryPayment.entity_id == entity_id,
|
||
TreasuryPayment.status.in_(ACTIVE_STATUSES),
|
||
TreasuryPayment.id != payment.id,
|
||
TreasuryPayment.submitter == payment.submitter,
|
||
TreasuryPayment.pay_date == pay_day,
|
||
)
|
||
day_total = _r(float(day_q.scalar() or 0) + amount)
|
||
upgraded = False
|
||
if daily_cum_limit is not None and base_req == 0 and day_total > float(daily_cum_limit) + TOL:
|
||
upgraded = True
|
||
|
||
# 多档取最严:叠加 treasury_limits(role/account/tier 维度)
|
||
limits_req, limits_scope = _limits_strictest(db, entity_id, payment, amount, cum_same)
|
||
|
||
required = max(base_req, cum_req, limits_req, 1 if upgraded else 0)
|
||
scopes = [f"amount_tier{base_scope}"]
|
||
if cum_req > base_req:
|
||
scopes.append(f"cumulative_tier{cum_scope}({cum_same})")
|
||
if limits_req:
|
||
scopes.append(f"limits:{limits_scope}")
|
||
if upgraded:
|
||
scopes.append(f"daily_cum_upgrade(total={day_total}>limit={_r(daily_cum_limit)})")
|
||
matched_scope = ";".join(scopes)
|
||
basis = (f"单笔 {amount} 档位 {base_scope}→{base_req}级;拆单累计(同提交人×同收款方×同自然日){cum_same}"
|
||
f"→{cum_req}级;取最严={required}级;边界左闭右开")
|
||
return {"rule_code": "AU-001", "verdict": "pass", "severity": rule.severity,
|
||
"required_approvals": required, "day_cum_amount": cum_same,
|
||
"detail": _hit_detail("AU-001", rule.rule_version, matched_scope,
|
||
_r(daily_cum_limit) if daily_cum_limit is not None else None,
|
||
cum_same, basis)}
|
||
|
||
|
||
def _limits_strictest(db, entity_id, payment, amount, cum_amount):
|
||
"""treasury_limits 中 role/account/tier 维度的最严档(不硬编码,走表)。"""
|
||
rows = db.query(TreasuryLimit).filter(
|
||
TreasuryLimit.entity_id == entity_id,
|
||
TreasuryLimit.status == "active",
|
||
).all()
|
||
role = _resolve_submitter_role(db, payment.submitter)
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.id == payment.from_account_id,
|
||
TreasuryAccount.entity_id == entity_id).first()
|
||
best, scope = 0, None
|
||
for r in rows:
|
||
if r.scope_type == "role" and (not role or r.scope_value != role):
|
||
continue
|
||
if r.scope_type == "account" and str(r.scope_value) != str(payment.from_account_id):
|
||
continue
|
||
if r.scope_type == "tier" and (not acc or r.scope_value != acc.tier):
|
||
continue
|
||
for amt in (amount, cum_amount):
|
||
if _ge(amt, r.amount_from) and (r.amount_to is None or _lt(amt, r.amount_to)):
|
||
if int(r.required_approvals or 0) > best:
|
||
best = int(r.required_approvals or 0)
|
||
scope = (f"{r.scope_type}={r.scope_value}@[{_r(r.amount_from)},"
|
||
f"{_r(r.amount_to) if r.amount_to is not None else '∞'})")
|
||
return best, scope
|
||
|
||
|
||
def _eval_bl001_requirement(db, entity_id, payment, rule) -> dict:
|
||
"""BL-001 预检阶段:登记"本单需双签"要求(≥min_amount);实质约束在 /approve 执行。"""
|
||
params = rule.params or {}
|
||
min_amount = float(params.get("min_amount") or 0)
|
||
if payment.pay_type == INTERNAL_TRANSFER:
|
||
return {"rule_code": "BL-001", "verdict": "pass", "severity": rule.severity,
|
||
"detail": _hit_detail("BL-001", rule.rule_version, "pay_type=内部划转",
|
||
_r(min_amount), _r(payment.amount),
|
||
"同一法人内部账户划转/归集不适用 BL-001(走单签+全量留痕)")}
|
||
if _ge(payment.amount, min_amount):
|
||
return {"rule_code": "BL-001", "verdict": "warn", "severity": rule.severity,
|
||
"detail": _hit_detail("BL-001", rule.rule_version,
|
||
f"amount>={_r(min_amount)}", _r(min_amount), _r(payment.amount),
|
||
"awaiting_dual_sign:需 2 个不同账号且不同自然人的审批(审批人"
|
||
"不得为提交人本人);未满足前不得置 approved")}
|
||
return {"rule_code": "BL-001", "verdict": "pass", "severity": rule.severity,
|
||
"detail": _hit_detail("BL-001", rule.rule_version, f"amount<{_r(min_amount)}",
|
||
_r(min_amount), _r(payment.amount), "未达双签门槛")}
|
||
|
||
|
||
def _eval_lq001(db, entity_id, payment, rule) -> dict:
|
||
"""LQ-001 流动性下限(首版 warn-only;estimated 头寸禁 block)。"""
|
||
params = rule.params or {}
|
||
target = float(params.get("target") or 0)
|
||
warn = float(params.get("warn") or 0)
|
||
block = float(params.get("block") or 0)
|
||
block_enabled = bool(params.get("block_enabled"))
|
||
bal = db.query(TreasuryBalance).filter(
|
||
TreasuryBalance.entity_id == entity_id,
|
||
TreasuryBalance.account_id == payment.from_account_id,
|
||
).order_by(TreasuryBalance.period.desc()).first()
|
||
if not bal:
|
||
return {"rule_code": "LQ-001", "verdict": "pass", "severity": "warn",
|
||
"detail": _hit_detail("LQ-001", rule.rule_version, "none", _r(warn), None,
|
||
"无头寸数据(no_position_data):不判定,不阻断")}
|
||
if bal.data_status == "estimated":
|
||
return {"rule_code": "LQ-001", "verdict": "pass", "severity": "warn",
|
||
"detail": _hit_detail("LQ-001", rule.rule_version,
|
||
f"account:{bal.account_id}@{bal.period}", _r(warn),
|
||
_r(bal.balance),
|
||
"skip_when=data_status=estimated(期初未落定,estimated 禁 block)")}
|
||
after = _r(float(bal.balance) - float(payment.amount))
|
||
if block_enabled and _lt(after, block):
|
||
verdict, basis = "blocked", f"支付后头寸 {after} < 阻断线 {_r(block)}"
|
||
elif _lt(after, warn):
|
||
verdict, basis = "warn", f"支付后头寸 {after} < 警戒线 {_r(warn)}"
|
||
elif _lt(after, target):
|
||
verdict, basis = "warn", f"支付后头寸 {after} < 目标线 {_r(target)}"
|
||
else:
|
||
verdict, basis = "pass", f"支付后头寸 {after} >= 目标线 {_r(target)}"
|
||
return {"rule_code": "LQ-001", "verdict": verdict, "severity": "warn",
|
||
"detail": _hit_detail("LQ-001", rule.rule_version,
|
||
f"account:{bal.account_id}@{bal.period}",
|
||
{"target": _r(target), "warn": _r(warn), "block": _r(block)},
|
||
after, basis)}
|
||
|
||
|
||
def eval_payment(db: Session, entity_id: int, payment: TreasuryPayment,
|
||
persist: bool = True) -> dict:
|
||
"""阶段一规则引擎主入口(预检):AC-001 / NP-001 / AU-001 / BL-001 / LQ-001。"""
|
||
rules = _load_rules(db, entity_id)
|
||
results: List[dict] = []
|
||
|
||
r_ac = rules.get("AC-001")
|
||
results.append(_eval_ac001(db, entity_id, payment, r_ac))
|
||
|
||
if r_ac and results[-1]["verdict"] == "blocked":
|
||
severity_all = "blocked"
|
||
detail = {"results": [{"rule_code": r["rule_code"], "verdict": r["verdict"],
|
||
"matched_scope": r["detail"]["matched_scope"]} for r in results],
|
||
"reason": "AC-001 账外账户拦截(后续规则不再评估)"}
|
||
if persist:
|
||
_record_hits(db, entity_id, "payment", payment.id, results)
|
||
return {"check_result": severity_all, "required_approvals": None,
|
||
"day_cum_amount": None, "check_detail": detail,
|
||
"dual_sign_required": False}
|
||
|
||
r_np = rules.get("NP-001")
|
||
if r_np:
|
||
results.append(_eval_np001(db, entity_id, payment, r_np))
|
||
r_au = rules.get("AU-001")
|
||
req = 0
|
||
day_cum = None
|
||
if r_au:
|
||
au = _eval_au001(db, entity_id, payment, r_au)
|
||
req = au["required_approvals"]
|
||
day_cum = au["day_cum_amount"]
|
||
results.append({k: v for k, v in au.items() if k not in ("required_approvals", "day_cum_amount")})
|
||
r_bl = rules.get("BL-001")
|
||
dual_required = False
|
||
if r_bl:
|
||
bl = _eval_bl001_requirement(db, entity_id, payment, r_bl)
|
||
dual_required = bl["verdict"] == "warn"
|
||
results.append(bl)
|
||
r_lq = rules.get("LQ-001")
|
||
if r_lq:
|
||
results.append(_eval_lq001(db, entity_id, payment, r_lq))
|
||
|
||
if any(x["verdict"] == "blocked" for x in results):
|
||
check_result = "blocked"
|
||
elif any(x["verdict"] in ("warn", "blocked") for x in results):
|
||
check_result = "warn"
|
||
else:
|
||
check_result = "pass"
|
||
|
||
detail = {"results": [{"rule_code": r["rule_code"], "verdict": r["verdict"],
|
||
"matched_scope": r["detail"]["matched_scope"],
|
||
"threshold": r["detail"]["threshold"],
|
||
"cumulative": r["detail"]["cumulative"],
|
||
"verdict_basis": r["detail"]["verdict_basis"],
|
||
"rule_version": r["detail"]["rule_version"]} for r in results],
|
||
"required_approvals": req,
|
||
"dual_sign_required": dual_required,
|
||
"evaluated_at": _now().isoformat(timespec="seconds")}
|
||
if persist:
|
||
_record_hits(db, entity_id, "payment", payment.id, results)
|
||
return {"check_result": check_result, "required_approvals": req,
|
||
"day_cum_amount": day_cum, "check_detail": detail,
|
||
"dual_sign_required": dual_required}
|
||
|
||
|
||
def eval_dual_sign_at_approve(db: Session, entity_id: int, payment: TreasuryPayment) -> dict:
|
||
"""BL-001 实质约束(审批时执行):两账号须不同自然人,否则形式双签(不放行,审计标红)。"""
|
||
rule = _load_rules(db, entity_id).get("BL-001")
|
||
if not rule:
|
||
return {"verdict": "pass", "basis": "BL-001 未启用"}
|
||
params = rule.params or {}
|
||
if payment.pay_type == INTERNAL_TRANSFER:
|
||
return {"verdict": "pass", "basis": "内部划转不适用 BL-001"}
|
||
min_amount = float(params.get("min_amount") or 0)
|
||
if _lt(payment.amount, min_amount):
|
||
return {"verdict": "pass", "basis": f"未达双签门槛 {_r(min_amount)} 万元"}
|
||
entries = payment.approvals or []
|
||
persons = {str(e.get("person_name") or e.get("account") or "")
|
||
for e in entries if e.get("account") != payment.submitter}
|
||
persons.discard("")
|
||
if len(persons) >= 2:
|
||
return {"verdict": "pass", "basis": f"双签有效:{len(persons)} 个不同自然人 {sorted(persons)}"}
|
||
return {"verdict": "blocked", "basis":
|
||
f"形式双签拦截:审批数 {len(entries)},不同自然人 {len(persons)} < 2"
|
||
f"(dual_sign_effective=0,审计标红,不作为有效双签放行)"}
|
||
|
||
|
||
def _dual_sign_effective(payment: TreasuryPayment) -> Optional[int]:
|
||
"""两账号是否属不同自然人:0=形式双签(放行无效,审计标红)。"""
|
||
entries = payment.approvals or []
|
||
persons = {str(e.get("person_name") or e.get("account") or "")
|
||
for e in entries if e.get("account") != payment.submitter}
|
||
persons.discard("")
|
||
return 1 if len(persons) >= 2 else 0
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Pydantic 输入
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
class AccountIn(BaseModel):
|
||
account_no: str
|
||
account_name: str
|
||
account_type: str
|
||
tier: str
|
||
bank_name: Optional[str] = None
|
||
subject_code: Optional[str] = None
|
||
currency: str = "CNY"
|
||
owner_node_id: Optional[int] = None
|
||
open_balance: Optional[float] = None
|
||
open_period: Optional[str] = None
|
||
is_pool_member: int = 0
|
||
status: str = "active"
|
||
remark: Optional[str] = None
|
||
|
||
|
||
class BalanceIn(BaseModel):
|
||
account_id: Optional[int] = None
|
||
account_no: Optional[str] = None
|
||
period: str
|
||
balance: float
|
||
available: Optional[float] = None
|
||
frozen: Optional[float] = None
|
||
source: str = "import"
|
||
data_status: str = "estimated" # fail-safe:verified 必须显式传
|
||
batch: Optional[str] = None
|
||
|
||
|
||
class RuleIn(BaseModel):
|
||
rule_code: str
|
||
rule_name: str
|
||
rule_type: str
|
||
severity: str
|
||
params: Optional[dict] = None
|
||
rule_version: str = "v1"
|
||
enabled: int = 1
|
||
remark: Optional[str] = None
|
||
|
||
|
||
class LimitIn(BaseModel):
|
||
scope_type: str
|
||
scope_value: str
|
||
amount_from: float
|
||
amount_to: Optional[float] = None
|
||
required_approvals: int = 1
|
||
approver_roles: List[str] = []
|
||
require_budget: int = 1
|
||
daily_cum_limit: Optional[float] = None
|
||
status: str = "active"
|
||
|
||
|
||
class PaymentIn(BaseModel):
|
||
pay_no: Optional[str] = None
|
||
from_account_id: int
|
||
to_name: str
|
||
to_account_no: Optional[str] = None
|
||
amount: float
|
||
currency: str = "CNY"
|
||
purpose: str
|
||
pay_type: Optional[str] = None
|
||
pay_date: Optional[str] = None
|
||
budget_plan_id: Optional[int] = None
|
||
kpi_id: Optional[int] = None
|
||
related_plan_id: Optional[int] = None
|
||
submitter: Optional[str] = None
|
||
|
||
|
||
class ApproveIn(BaseModel):
|
||
approve: bool = True
|
||
comment: Optional[str] = None
|
||
|
||
|
||
class WaiveIn(BaseModel):
|
||
waived_by: str = Field(..., description="豁免审批人(姓名/账号)")
|
||
waived_reason: str = Field(..., min_length=4, description="豁免事由(必填)")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# A. 账户主数据
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _account_dict(a: TreasuryAccount) -> dict:
|
||
return {
|
||
"id": a.id, "entity_id": a.entity_id, "account_no": a.account_no,
|
||
"account_name": a.account_name, "account_type": a.account_type,
|
||
"bank_name": a.bank_name, "subject_code": a.subject_code, "currency": a.currency,
|
||
"tier": a.tier, "tier_basis": a.tier_basis, "owner_node_id": a.owner_node_id,
|
||
"open_balance": a.open_balance, "open_period": a.open_period,
|
||
"is_pool_member": a.is_pool_member, "status": a.status, "remark": a.remark,
|
||
}
|
||
|
||
|
||
@router.get("/accounts")
|
||
def list_accounts(status: Optional[str] = None, tier: Optional[str] = None,
|
||
account_type: Optional[str] = None, keyword: Optional[str] = None,
|
||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
q = db.query(TreasuryAccount).filter(TreasuryAccount.entity_id == entity_id)
|
||
if status:
|
||
q = q.filter(TreasuryAccount.status == status)
|
||
if tier:
|
||
q = q.filter(TreasuryAccount.tier == tier)
|
||
if account_type:
|
||
q = q.filter(TreasuryAccount.account_type == account_type)
|
||
if keyword:
|
||
like = f"%{keyword}%"
|
||
q = q.filter(or_(TreasuryAccount.account_name.like(like),
|
||
TreasuryAccount.account_no.like(like),
|
||
TreasuryAccount.subject_code.like(like)))
|
||
rows = q.order_by(TreasuryAccount.account_no).all()
|
||
return {"total": len(rows), "items": [_account_dict(a) for a in rows]}
|
||
|
||
|
||
@router.post("/accounts")
|
||
def create_account(payload: AccountIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
if payload.tier not in ("hq", "platform", "grassroots"):
|
||
raise HTTPException(400, "tier 取值须为 hq/platform/grassroots(按账户性质判定,禁按 level 派生)")
|
||
if payload.owner_node_id:
|
||
node = db.query(OrgNode).filter(OrgNode.id == payload.owner_node_id,
|
||
OrgNode.entity_id == entity_id).first()
|
||
if not node:
|
||
raise HTTPException(400, "owner_node_id 不存在于当前账套(禁造虚层级)")
|
||
exists = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.entity_id == entity_id,
|
||
TreasuryAccount.account_no == payload.account_no).first()
|
||
if exists:
|
||
raise HTTPException(409, f"账号 {payload.account_no} 已在该账套登记(唯一键 uq_tacct_entity_no)")
|
||
acc = TreasuryAccount(entity_id=entity_id, **payload.model_dump())
|
||
acc.open_balance = None if payload.open_balance is None else _r(payload.open_balance)
|
||
db.add(acc)
|
||
db.commit()
|
||
db.refresh(acc)
|
||
return _account_dict(acc)
|
||
|
||
|
||
@router.put("/accounts/{acc_id}")
|
||
def update_account(acc_id: int, payload: AccountIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
acc = _pick_account(db, entity_id, acc_id)
|
||
for k, v in payload.model_dump().items():
|
||
setattr(acc, k, _r(v) if k == "open_balance" and v is not None else v)
|
||
db.commit()
|
||
db.refresh(acc)
|
||
return _account_dict(acc)
|
||
|
||
|
||
@router.delete("/accounts/{acc_id}")
|
||
def delete_account(acc_id: int, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
acc = _pick_account(db, entity_id, acc_id)
|
||
used = db.query(TreasuryPayment).filter(
|
||
TreasuryPayment.entity_id == entity_id,
|
||
TreasuryPayment.from_account_id == acc_id).count()
|
||
bal = db.query(TreasuryBalance).filter(
|
||
TreasuryBalance.entity_id == entity_id,
|
||
TreasuryBalance.account_id == acc_id).count()
|
||
if used or bal:
|
||
raise HTTPException(409, f"账户已被引用(支付单 {used} 笔 / 头寸 {bal} 行),请置 inactive 而非删除")
|
||
db.delete(acc)
|
||
db.commit()
|
||
return {"deleted": acc_id}
|
||
|
||
|
||
@router.post("/accounts/import")
|
||
def import_accounts(rows: List[AccountIn], db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
"""批量导入(幂等:按 (entity_id, account_no) upsert)。F1 账户主数据清单即走此通道。"""
|
||
created = updated = 0
|
||
for row in rows:
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.entity_id == entity_id,
|
||
TreasuryAccount.account_no == row.account_no).first()
|
||
data = row.model_dump()
|
||
if data.get("open_balance") is not None:
|
||
data["open_balance"] = _r(data["open_balance"])
|
||
if acc:
|
||
for k, v in data.items():
|
||
setattr(acc, k, v)
|
||
updated += 1
|
||
else:
|
||
db.add(TreasuryAccount(entity_id=entity_id, **data))
|
||
created += 1
|
||
db.commit()
|
||
return {"entity_id": entity_id, "created": created, "updated": updated, "total": len(rows)}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# B. 头寸
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _balance_dict(b: TreasuryBalance) -> dict:
|
||
return {"id": b.id, "entity_id": b.entity_id, "account_id": b.account_id,
|
||
"period": b.period, "balance": b.balance, "available": b.available,
|
||
"frozen": b.frozen, "source": b.source, "data_status": b.data_status,
|
||
"batch": b.batch, "created_at": b.created_at}
|
||
|
||
|
||
def _upsert_balance(db, entity_id, row: BalanceIn) -> str:
|
||
"""按 (account_id, period) 幂等 upsert;写入前校验账户同实体(G1 附加验收①)。"""
|
||
acc = None
|
||
if row.account_id:
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.id == row.account_id,
|
||
TreasuryAccount.entity_id == entity_id).first()
|
||
elif row.account_no:
|
||
acc = db.query(TreasuryAccount).filter(
|
||
TreasuryAccount.entity_id == entity_id,
|
||
TreasuryAccount.account_no == row.account_no).first()
|
||
if not acc:
|
||
raise HTTPException(400, f"账户未登记或不属于当前账套(account_id={row.account_id} "
|
||
f"account_no={row.account_no})—— 头寸禁止跨实体误挂")
|
||
if row.data_status not in ("verified", "estimated"):
|
||
raise HTTPException(400, "data_status 须为 verified/estimated")
|
||
data = {"balance": _r(row.balance),
|
||
"available": None if row.available is None else _r(row.available),
|
||
"frozen": _r(row.frozen or 0), "source": row.source,
|
||
"data_status": row.data_status, "batch": row.batch}
|
||
bal = db.query(TreasuryBalance).filter(
|
||
TreasuryBalance.entity_id == entity_id,
|
||
TreasuryBalance.account_id == acc.id,
|
||
TreasuryBalance.period == row.period).first()
|
||
if bal:
|
||
for k, v in data.items():
|
||
setattr(bal, k, v)
|
||
return "updated"
|
||
db.add(TreasuryBalance(entity_id=entity_id, account_id=acc.id, period=row.period, **data))
|
||
return "created"
|
||
|
||
|
||
@router.get("/balances")
|
||
def list_balances(account_id: Optional[int] = None, period_from: Optional[str] = None,
|
||
period_to: Optional[str] = None, data_status: Optional[str] = None,
|
||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
q = db.query(TreasuryBalance).filter(TreasuryBalance.entity_id == entity_id)
|
||
if account_id:
|
||
q = q.filter(TreasuryBalance.account_id == account_id)
|
||
if period_from:
|
||
q = q.filter(TreasuryBalance.period >= period_from)
|
||
if period_to:
|
||
q = q.filter(TreasuryBalance.period <= period_to)
|
||
if data_status:
|
||
q = q.filter(TreasuryBalance.data_status == data_status)
|
||
rows = q.order_by(TreasuryBalance.period.desc(), TreasuryBalance.account_id).all()
|
||
return {"total": len(rows), "items": [_balance_dict(b) for b in rows]}
|
||
|
||
|
||
@router.post("/balances")
|
||
def upsert_balance(row: BalanceIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
action = _upsert_balance(db, entity_id, row)
|
||
db.commit()
|
||
return {"action": action, "account": row.account_id or row.account_no, "period": row.period}
|
||
|
||
|
||
@router.post("/balances/import")
|
||
def import_balances(rows: List[BalanceIn], db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
"""批量导入(幂等:同一 period 重复导入结果不变)。"""
|
||
created = updated = 0
|
||
for row in rows:
|
||
action = _upsert_balance(db, entity_id, row)
|
||
created += action == "created"
|
||
updated += action == "updated"
|
||
db.commit()
|
||
total = db.query(TreasuryBalance).filter(TreasuryBalance.entity_id == entity_id).count()
|
||
return {"entity_id": entity_id, "created": created, "updated": updated,
|
||
"rows_in": len(rows), "balances_total": total}
|
||
|
||
|
||
@router.get("/positions/summary")
|
||
def positions_summary(period: Optional[str] = None, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id)):
|
||
"""实体头寸汇总:按 account_type / tier / data_status 分解(账户级 → 实体级)。"""
|
||
accounts = db.query(TreasuryAccount).filter(TreasuryAccount.entity_id == entity_id).all()
|
||
q = db.query(TreasuryBalance).filter(TreasuryBalance.entity_id == entity_id)
|
||
if period:
|
||
q = q.filter(TreasuryBalance.period == period)
|
||
balances = q.all()
|
||
latest: Dict[int, TreasuryBalance] = {}
|
||
for b in balances:
|
||
cur = latest.get(b.account_id)
|
||
if cur is None or b.period > cur.period:
|
||
latest[b.account_id] = b
|
||
by_type: Dict[str, float] = {}
|
||
by_tier: Dict[str, float] = {}
|
||
by_status: Dict[str, int] = {}
|
||
total = 0.0
|
||
for a in accounts:
|
||
b = latest.get(a.id)
|
||
amt = float(b.balance) if b else float(a.open_balance or 0)
|
||
src = (b.data_status if b else ("estimated" if a.open_balance is None else "verified"))
|
||
total += amt
|
||
by_type[a.account_type] = _r(by_type.get(a.account_type, 0) + amt)
|
||
by_tier[a.tier] = _r(by_tier.get(a.tier, 0) + amt)
|
||
by_status[src] = by_status.get(src, 0) + 1
|
||
return {"entity_id": entity_id, "period": period, "accounts": len(accounts),
|
||
"total_balance": _r(total), "by_account_type": by_type, "by_tier": by_tier,
|
||
"by_data_status": by_status,
|
||
"unit": "万元", "accounts_without_position": len(accounts) - len(latest)}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# C. 规则库 / 授权矩阵
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _rule_dict(r: TreasuryControlRule) -> dict:
|
||
return {"id": r.id, "entity_id": r.entity_id, "rule_code": r.rule_code,
|
||
"rule_name": r.rule_name, "rule_type": r.rule_type, "severity": r.severity,
|
||
"params": r.params, "rule_version": r.rule_version, "enabled": r.enabled,
|
||
"remark": r.remark}
|
||
|
||
|
||
def _limit_dict(r: TreasuryLimit) -> dict:
|
||
return {"id": r.id, "entity_id": r.entity_id, "scope_type": r.scope_type,
|
||
"scope_value": r.scope_value, "amount_from": r.amount_from,
|
||
"amount_to": r.amount_to, "required_approvals": r.required_approvals,
|
||
"approver_roles": r.approver_roles, "require_budget": r.require_budget,
|
||
"daily_cum_limit": r.daily_cum_limit, "status": r.status}
|
||
|
||
|
||
@router.get("/control-rules")
|
||
def list_rules(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
rows = db.query(TreasuryControlRule).filter(
|
||
TreasuryControlRule.entity_id == entity_id).order_by(TreasuryControlRule.rule_code).all()
|
||
return {"total": len(rows), "items": [_rule_dict(r) for r in rows]}
|
||
|
||
|
||
@router.post("/control-rules")
|
||
def create_rule(payload: RuleIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
exists = db.query(TreasuryControlRule).filter(
|
||
TreasuryControlRule.entity_id == entity_id,
|
||
TreasuryControlRule.rule_code == payload.rule_code).first()
|
||
if exists:
|
||
raise HTTPException(409, f"规则 {payload.rule_code} 已存在(唯一键 uq_tcr_entity_code)")
|
||
r = TreasuryControlRule(entity_id=entity_id, **payload.model_dump())
|
||
db.add(r)
|
||
db.commit()
|
||
db.refresh(r)
|
||
return _rule_dict(r)
|
||
|
||
|
||
@router.put("/control-rules/{rule_id}")
|
||
def update_rule(rule_id: int, payload: RuleIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
r = db.query(TreasuryControlRule).filter(
|
||
TreasuryControlRule.id == rule_id,
|
||
TreasuryControlRule.entity_id == entity_id).first()
|
||
if not r:
|
||
raise HTTPException(404, "规则不存在或不属于当前账套")
|
||
for k, v in payload.model_dump().items():
|
||
setattr(r, k, v)
|
||
db.commit()
|
||
db.refresh(r)
|
||
return _rule_dict(r)
|
||
|
||
|
||
@router.delete("/control-rules/{rule_id}")
|
||
def delete_rule(rule_id: int, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "it"))):
|
||
r = db.query(TreasuryControlRule).filter(
|
||
TreasuryControlRule.id == rule_id,
|
||
TreasuryControlRule.entity_id == entity_id).first()
|
||
if not r:
|
||
raise HTTPException(404, "规则不存在或不属于当前账套")
|
||
db.delete(r)
|
||
db.commit()
|
||
return {"deleted": rule_id}
|
||
|
||
|
||
@router.get("/limits")
|
||
def list_limits(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
rows = db.query(TreasuryLimit).filter(TreasuryLimit.entity_id == entity_id).order_by(
|
||
TreasuryLimit.scope_type, TreasuryLimit.scope_value, TreasuryLimit.amount_from).all()
|
||
return {"total": len(rows), "items": [_limit_dict(r) for r in rows]}
|
||
|
||
|
||
@router.post("/limits")
|
||
def create_limit(payload: LimitIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
data = payload.model_dump()
|
||
data["amount_from"] = _r(data["amount_from"])
|
||
data["amount_to"] = None if data["amount_to"] is None else _r(data["amount_to"])
|
||
data["daily_cum_limit"] = None if data["daily_cum_limit"] is None else _r(data["daily_cum_limit"])
|
||
l = TreasuryLimit(entity_id=entity_id, **data)
|
||
db.add(l)
|
||
db.commit()
|
||
db.refresh(l)
|
||
return _limit_dict(l)
|
||
|
||
|
||
@router.put("/limits/{limit_id}")
|
||
def update_limit(limit_id: int, payload: LimitIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
l = db.query(TreasuryLimit).filter(TreasuryLimit.id == limit_id,
|
||
TreasuryLimit.entity_id == entity_id).first()
|
||
if not l:
|
||
raise HTTPException(404, "授权矩阵行不存在或不属于当前账套")
|
||
for k, v in payload.model_dump().items():
|
||
setattr(l, k, _r(v) if k in ("amount_from", "amount_to", "daily_cum_limit") and v is not None else v)
|
||
db.commit()
|
||
db.refresh(l)
|
||
return _limit_dict(l)
|
||
|
||
|
||
@router.delete("/limits/{limit_id}")
|
||
def delete_limit(limit_id: int, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "it"))):
|
||
l = db.query(TreasuryLimit).filter(TreasuryLimit.id == limit_id,
|
||
TreasuryLimit.entity_id == entity_id).first()
|
||
if not l:
|
||
raise HTTPException(404, "授权矩阵行不存在或不属于当前账套")
|
||
db.delete(l)
|
||
db.commit()
|
||
return {"deleted": limit_id}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# D. 支付单 + 内控闸门
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
def _payment_dict(p: TreasuryPayment) -> dict:
|
||
return {"id": p.id, "entity_id": p.entity_id, "pay_no": p.pay_no,
|
||
"from_account_id": p.from_account_id, "to_name": p.to_name,
|
||
"to_account_no": p.to_account_no, "amount": p.amount, "currency": p.currency,
|
||
"purpose": p.purpose, "pay_type": p.pay_type,
|
||
"pay_date": p.pay_date.isoformat() if p.pay_date else None,
|
||
"budget_plan_id": p.budget_plan_id, "kpi_id": p.kpi_id,
|
||
"related_plan_id": p.related_plan_id, "status": p.status,
|
||
"check_result": p.check_result, "check_detail": p.check_detail,
|
||
"required_approvals": p.required_approvals, "day_cum_amount": p.day_cum_amount,
|
||
"approvals": p.approvals or [], "dual_sign_effective": p.dual_sign_effective,
|
||
"is_waived": p.is_waived, "submitter": p.submitter, "created_by": p.created_by,
|
||
"submitted_at": p.submitted_at, "approved_at": p.approved_at,
|
||
"paid_at": p.paid_at, "created_at": p.created_at}
|
||
|
||
|
||
def _gen_pay_no(db, entity_id) -> str:
|
||
today = datetime.now().strftime("%Y%m%d")
|
||
n = db.query(func.count(TreasuryPayment.id)).filter(
|
||
TreasuryPayment.entity_id == entity_id,
|
||
TreasuryPayment.pay_no.like(f"TP{today}%")).scalar() or 0
|
||
return f"TP{today}{int(n) + 1:04d}"
|
||
|
||
|
||
def _get_payment(db, entity_id, pid) -> TreasuryPayment:
|
||
p = db.query(TreasuryPayment).filter(TreasuryPayment.id == pid,
|
||
TreasuryPayment.entity_id == entity_id).first()
|
||
if not p:
|
||
raise HTTPException(404, f"支付单 {pid} 不存在或不属于当前账套")
|
||
return p
|
||
|
||
|
||
@router.get("/payments")
|
||
def list_payments(status: Optional[str] = None, check_result: Optional[str] = None,
|
||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
q = db.query(TreasuryPayment).filter(TreasuryPayment.entity_id == entity_id)
|
||
if status:
|
||
q = q.filter(TreasuryPayment.status == status)
|
||
if check_result:
|
||
q = q.filter(TreasuryPayment.check_result == check_result)
|
||
rows = q.order_by(TreasuryPayment.id.desc()).limit(500).all()
|
||
return {"total": len(rows), "items": [_payment_dict(p) for p in rows]}
|
||
|
||
|
||
@router.post("/payments")
|
||
def create_payment(payload: PaymentIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
acc = _pick_account(db, entity_id, payload.from_account_id) # 同实体校验
|
||
submitter = payload.submitter or (current_user.name or current_user.username)
|
||
if payload.related_plan_id:
|
||
plan = db.query(CashPlan).filter(CashPlan.id == payload.related_plan_id,
|
||
CashPlan.entity_id == entity_id).first()
|
||
if not plan:
|
||
raise HTTPException(400, "related_plan_id 不在当前账套(cash_plans 只读复用,禁跨实体)")
|
||
if payload.budget_plan_id:
|
||
bp = db.query(BudgetPlan).filter(BudgetPlan.id == payload.budget_plan_id,
|
||
BudgetPlan.entity_id == entity_id).first()
|
||
if not bp:
|
||
raise HTTPException(400, "budget_plan_id 不在当前账套")
|
||
p = TreasuryPayment(
|
||
entity_id=entity_id,
|
||
pay_no=payload.pay_no or _gen_pay_no(db, entity_id),
|
||
from_account_id=acc.id,
|
||
to_name=payload.to_name,
|
||
to_account_no=payload.to_account_no,
|
||
amount=_r(payload.amount),
|
||
currency=payload.currency,
|
||
purpose=payload.purpose,
|
||
pay_type=payload.pay_type,
|
||
pay_date=date.fromisoformat(payload.pay_date) if payload.pay_date else date.today(),
|
||
budget_plan_id=payload.budget_plan_id,
|
||
kpi_id=payload.kpi_id,
|
||
related_plan_id=payload.related_plan_id,
|
||
status="draft",
|
||
submitter=submitter,
|
||
created_by=current_user.username if hasattr(current_user, "username") else submitter,
|
||
)
|
||
db.add(p)
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
|
||
|
||
@router.put("/payments/{pid}")
|
||
def update_payment(pid: int, payload: PaymentIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
"""草稿/预检态支付单修改(已进入审批链后不可改,需作废重建)。"""
|
||
p = _get_payment(db, entity_id, pid)
|
||
if p.status not in ("draft", "precheck"):
|
||
raise HTTPException(409, f"支付单状态 {p.status} 不可修改(审批链已开始)")
|
||
acc = _pick_account(db, entity_id, payload.from_account_id)
|
||
if payload.budget_plan_id:
|
||
bp = db.query(BudgetPlan).filter(BudgetPlan.id == payload.budget_plan_id,
|
||
BudgetPlan.entity_id == entity_id).first()
|
||
if not bp:
|
||
raise HTTPException(400, "budget_plan_id 不在当前账套")
|
||
if payload.related_plan_id:
|
||
plan = db.query(CashPlan).filter(CashPlan.id == payload.related_plan_id,
|
||
CashPlan.entity_id == entity_id).first()
|
||
if not plan:
|
||
raise HTTPException(400, "related_plan_id 不在当前账套")
|
||
p.from_account_id = acc.id
|
||
p.to_name = payload.to_name
|
||
p.to_account_no = payload.to_account_no
|
||
p.amount = _r(payload.amount)
|
||
p.currency = payload.currency
|
||
p.purpose = payload.purpose
|
||
p.pay_type = payload.pay_type
|
||
p.pay_date = date.fromisoformat(payload.pay_date) if payload.pay_date else p.pay_date
|
||
p.budget_plan_id = payload.budget_plan_id
|
||
p.kpi_id = payload.kpi_id
|
||
p.related_plan_id = payload.related_plan_id
|
||
if payload.submitter:
|
||
p.submitter = payload.submitter
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
|
||
|
||
@router.post("/payments/{pid}/precheck")
|
||
def precheck_payment(pid: int, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_auth)):
|
||
"""预检(规则引擎):命中拦截 → check_result=blocked(可走豁免);否则转 pending_approval。"""
|
||
p = _get_payment(db, entity_id, pid)
|
||
if p.status in ("approved", "paid"):
|
||
raise HTTPException(409, f"支付单已 {p.status},不可重复预检")
|
||
res = eval_payment(db, entity_id, p, persist=True)
|
||
p.check_result = res["check_result"]
|
||
p.check_detail = res["check_detail"]
|
||
p.required_approvals = res["required_approvals"]
|
||
p.day_cum_amount = res["day_cum_amount"]
|
||
p.submitted_at = p.submitted_at or _now()
|
||
if res["check_result"] == "blocked":
|
||
p.status = "precheck" # 被拦:不得进入 pending_approval
|
||
else:
|
||
p.status = "pending_approval"
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
|
||
|
||
@router.post("/payments/{pid}/approve")
|
||
def approve_payment(pid: int, payload: ApproveIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance"))):
|
||
"""审批(双签校验):同一账号重复审批无效;审批人不得为提交人本人;BL-001 需不同自然人。"""
|
||
p = _get_payment(db, entity_id, pid)
|
||
if p.check_result == "blocked" and not p.is_waived:
|
||
raise HTTPException(409, "该支付单已被 block 规则拦截,请先走豁免通道(/waive)")
|
||
if p.status in ("paid", "cancelled", "rejected"):
|
||
raise HTTPException(409, f"支付单状态 {p.status} 不可审批")
|
||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||
account = current_user.username
|
||
if account == p.submitter or approver == p.submitter:
|
||
raise HTTPException(403, "审批人不得为提交人本人(口径4)")
|
||
entries = list(p.approvals or [])
|
||
if any(e.get("account") == account for e in entries):
|
||
raise HTTPException(400, "同一账号重复审批无效(须不同账号,口径4)")
|
||
if not payload.approve:
|
||
p.approvals = entries
|
||
p.status = "rejected"
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
entries.append({"account": account, "role": current_user.role, "person_name": approver,
|
||
"at": _now().isoformat(timespec="seconds"), "comment": payload.comment})
|
||
p.approvals = entries
|
||
|
||
need = int(p.required_approvals or 0)
|
||
dual = eval_dual_sign_at_approve(db, entity_id, p)
|
||
p.dual_sign_effective = _dual_sign_effective(p)
|
||
if need >= 2:
|
||
persons = {e.get("person_name") for e in entries}
|
||
if len(persons) < 2:
|
||
p.status = "pending_approval" # 未达双签:不得置 approved
|
||
db.add(TreasuryControlHit(
|
||
entity_id=entity_id, target_type="payment", target_id=p.id, rule_code="BL-001",
|
||
verdict="blocked",
|
||
detail=_hit_detail("BL-001", "v1", f"required_approvals={need}", need,
|
||
len(persons),
|
||
"审批数不足:" + dual["basis"])))
|
||
db.commit()
|
||
db.refresh(p)
|
||
return {"payment": _payment_dict(p), "approved": False,
|
||
"reason": "required_approvals=%d 未满足:需 %d 个不同自然人的审批" % (need, need),
|
||
"dual_sign_basis": dual["basis"]}
|
||
if dual["verdict"] == "blocked":
|
||
db.add(TreasuryControlHit(
|
||
entity_id=entity_id, target_type="payment", target_id=p.id, rule_code="BL-001",
|
||
verdict="blocked",
|
||
detail=_hit_detail("BL-001", "v1", "approve", need, len(entries), dual["basis"])))
|
||
db.commit()
|
||
db.refresh(p)
|
||
return {"payment": _payment_dict(p), "approved": False, "reason": dual["basis"]}
|
||
p.status = "approved"
|
||
p.approved_at = _now()
|
||
db.add(TreasuryControlHit(
|
||
entity_id=entity_id, target_type="payment", target_id=p.id, rule_code="BL-001",
|
||
verdict="pass",
|
||
detail=_hit_detail("BL-001", "v1", "approve", need, len(entries), dual["basis"])))
|
||
db.commit()
|
||
db.refresh(p)
|
||
return {"payment": _payment_dict(p), "approved": True, "reason": dual["basis"]}
|
||
|
||
|
||
@router.post("/payments/{pid}/pay")
|
||
def pay_payment(pid: int, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance"))):
|
||
p = _get_payment(db, entity_id, pid)
|
||
if p.status != "approved":
|
||
raise HTTPException(409, f"支付单状态 {p.status},未放行不得登记支付")
|
||
p.status = "paid"
|
||
p.paid_at = _now()
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
|
||
|
||
@router.post("/payments/{pid}/waive")
|
||
def waive_payment(pid: int, payload: WaiveIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
"""豁免通道:必填审批人 + 事由;豁免级别不得低于该笔所需审批级数(≥B 需 ceo)。全量留痕。"""
|
||
p = _get_payment(db, entity_id, pid)
|
||
if p.check_result != "blocked":
|
||
raise HTTPException(409, "仅被 block 规则拦截的支付单可走豁免通道")
|
||
need = 1 if int(p.required_approvals or 0) < 2 else 2
|
||
wu = db.query(User).filter(or_(User.name == payload.waived_by,
|
||
User.username == payload.waived_by)).first()
|
||
if not wu:
|
||
raise HTTPException(400, "豁免审批人不存在(须为系统用户姓名或账号)")
|
||
if need >= 2 and wu.role != "ceo":
|
||
raise HTTPException(403, f"本单所需审批级数 {need},豁免须 ceo 级审批人(口径4:豁免级别不得低于该笔所需)")
|
||
p.is_waived = 1
|
||
entries = list(p.approvals or [])
|
||
entries.append({"account": wu.username, "role": wu.role, "person_name": wu.name,
|
||
"at": _now().isoformat(timespec="seconds"),
|
||
"comment": f"豁免:{payload.waived_reason}"})
|
||
p.approvals = entries
|
||
p.dual_sign_effective = _dual_sign_effective(p)
|
||
p.status = "approved"
|
||
p.approved_at = _now()
|
||
db.add(TreasuryControlHit(
|
||
entity_id=entity_id, target_type="payment", target_id=p.id, rule_code="WAIVE",
|
||
verdict="waived",
|
||
detail=_hit_detail("WAIVE", "v1", f"required_approvals={need}", need, None,
|
||
f"豁免放行:事由={payload.waived_reason}"),
|
||
waived_by=payload.waived_by, waived_reason=payload.waived_reason))
|
||
db.commit()
|
||
db.refresh(p)
|
||
return _payment_dict(p)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# E. 命中留痕 / 试算 / 总览
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/control-hits")
|
||
def list_hits(rule_code: Optional[str] = None, verdict: Optional[str] = None,
|
||
target_id: Optional[int] = None, limit: int = Query(200, le=1000),
|
||
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
q = db.query(TreasuryControlHit).filter(TreasuryControlHit.entity_id == entity_id)
|
||
if rule_code:
|
||
q = q.filter(TreasuryControlHit.rule_code == rule_code)
|
||
if verdict:
|
||
q = q.filter(TreasuryControlHit.verdict == verdict)
|
||
if target_id:
|
||
q = q.filter(TreasuryControlHit.target_id == target_id)
|
||
rows = q.order_by(TreasuryControlHit.id.desc()).limit(limit).all()
|
||
return {"total": len(rows), "items": [{
|
||
"id": h.id, "entity_id": h.entity_id, "target_type": h.target_type,
|
||
"target_id": h.target_id, "rule_code": h.rule_code, "verdict": h.verdict,
|
||
"detail": h.detail, "waived_by": h.waived_by, "waived_reason": h.waived_reason,
|
||
"created_at": h.created_at} for h in rows]}
|
||
|
||
|
||
@router.post("/control-rules/test")
|
||
def test_rules(payload: PaymentIn, db: Session = Depends(get_db),
|
||
entity_id: int = Depends(get_entity_id),
|
||
current_user: User = Depends(require_role("ceo", "finance", "it"))):
|
||
"""规则试算(不落库、不写留痕):给一笔模拟支付单,返回引擎判定。"""
|
||
submitter = payload.submitter or (current_user.name or current_user.username)
|
||
p = TreasuryPayment(
|
||
entity_id=entity_id, pay_no=payload.pay_no or "SIMULATED",
|
||
from_account_id=payload.from_account_id, to_name=payload.to_name,
|
||
to_account_no=payload.to_account_no, amount=_r(payload.amount),
|
||
currency=payload.currency, purpose=payload.purpose, pay_type=payload.pay_type,
|
||
pay_date=date.fromisoformat(payload.pay_date) if payload.pay_date else date.today(),
|
||
budget_plan_id=payload.budget_plan_id, kpi_id=payload.kpi_id,
|
||
related_plan_id=payload.related_plan_id, submitter=submitter, created_by=submitter,
|
||
id=0, status="simulated",
|
||
)
|
||
res = eval_payment(db, entity_id, p, persist=False)
|
||
db.rollback()
|
||
return {"simulated": True, **res}
|
||
|
||
|
||
@router.get("/dashboard")
|
||
def treasury_dashboard(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||
"""资金总览:账户池 + 头寸 + 规则命中 + 待批支付单。"""
|
||
accounts = db.query(TreasuryAccount).filter(TreasuryAccount.entity_id == entity_id).all()
|
||
balances = db.query(TreasuryBalance).filter(TreasuryBalance.entity_id == entity_id).all()
|
||
latest: Dict[int, TreasuryBalance] = {}
|
||
for b in balances:
|
||
cur = latest.get(b.account_id)
|
||
if cur is None or b.period > cur.period:
|
||
latest[b.account_id] = b
|
||
tier_stat: Dict[str, int] = {}
|
||
type_stat: Dict[str, int] = {}
|
||
total = 0.0
|
||
for a in accounts:
|
||
tier_stat[a.tier] = tier_stat.get(a.tier, 0) + 1
|
||
type_stat[a.account_type] = type_stat.get(a.account_type, 0) + 1
|
||
b = latest.get(a.id)
|
||
total += float(b.balance) if b else float(a.open_balance or 0)
|
||
payments = db.query(TreasuryPayment).filter(TreasuryPayment.entity_id == entity_id)
|
||
pending = payments.filter(TreasuryPayment.status == "pending_approval").count()
|
||
blocked = payments.filter(TreasuryPayment.check_result == "blocked",
|
||
TreasuryPayment.is_waived == 0).count()
|
||
hits = db.query(TreasuryControlHit).filter(TreasuryControlHit.entity_id == entity_id)
|
||
return {
|
||
"entity_id": entity_id,
|
||
"accounts": {"total": len(accounts),
|
||
"by_tier": tier_stat, "by_type": type_stat,
|
||
"active": sum(1 for a in accounts if a.status == "active")},
|
||
"balance_total": _r(total), "unit": "万元",
|
||
"positions": {"rows": len(balances),
|
||
"verified_accounts": sum(1 for b in latest.values() if b.data_status == "verified"),
|
||
"estimated_accounts": sum(1 for b in latest.values() if b.data_status == "estimated"),
|
||
"accounts_without_position": len(accounts) - len(latest)},
|
||
"payments": {"total": payments.count(), "pending_approval": pending, "blocked": blocked},
|
||
"hits": {"total": hits.count(),
|
||
"blocked": hits.filter(TreasuryControlHit.verdict == "blocked").count(),
|
||
"warn": hits.filter(TreasuryControlHit.verdict == "warn").count(),
|
||
"waived": hits.filter(TreasuryControlHit.verdict == "waived").count()},
|
||
}
|