Files
cma-management/backend/app/api/treasury.py
T
Hermes CI Fix 2ca53b272a
ci/woodpecker/push/main Pipeline was canceled
fix(cma): AU-001 day_cum_amount 返回日累计(day_total) 而非拆单累计
day_cum_amount 原返回 cum_same(同收款方拆单累计),与字段语义(日累计)不符;
日累计升级用例 T8 期望 day_cum=当日同提交人累计。改为 day_total。

Ref: 0-P0-project-20260915-cma-treasury-stage1 自验 T8
2026-09-16 14:56:57 +08:00

1200 lines
57 KiB
Python
Raw 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.
"""司库(资金)体系 · 阶段一 端点层 — /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": "warn", "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 ("blocked" if severity == "block" 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": day_total,
"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()},
}