feat(cma): treasury stage-1 MVP - migration, models, endpoints, rules, 2 pages

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).
This commit is contained in:
Hermes CI Fix
2026-09-15 20:02:56 +08:00
parent fdf805ab8f
commit e5eb092d20
12 changed files with 3472 additions and 4 deletions
+178
View File
@@ -0,0 +1,178 @@
"""司库(资金)体系 · 阶段一 ORM 模型 — 管理会计OS
对齐 DDL: backend/scripts/treasury_ddl.sql(md5 见阶段1回报)
G1 裁定:**不建物理外键**(entity_id 亦不建)——软链 + 应用层校验,覆盖说明见 DDL §F-2。
金额:FLOAT(万元),写库前 round(x,4),比较用容差 1e-4(禁用浮点等值比较)。
多租户:6 张表全部 entity_id NOT NULL(铁律)。
"""
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Date, JSON, Text, Index,
UniqueConstraint, func,
)
from app.database import Base
class TreasuryAccount(Base):
"""司库·资金账户主数据(统一管理:台账唯一入口)"""
__tablename__ = "treasury_accounts"
__table_args__ = (
UniqueConstraint("entity_id", "account_no", name="uq_tacct_entity_no"),
Index("ix_tacct_entity", "entity_id", "status"),
Index("ix_tacct_subject", "entity_id", "subject_code"),
{"comment": "司库·资金账户主数据"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户铁律,必带)")
account_no = Column(String(64), nullable=False, comment="账号/虚拟账户号(掩码)")
account_name = Column(String(100), nullable=False, comment="账户名")
account_type = Column(String(20), nullable=False, comment="cash/bank/e_wallet/other")
bank_name = Column(String(100), nullable=True)
subject_code = Column(String(20), nullable=True, comment="对应科目 1001xx/1002xx")
currency = Column(String(10), default="CNY")
tier = Column(String(20), nullable=False, comment="hq/platform/grassroots(按账户性质判定)")
tier_basis = Column(String(20), default="nature", comment="[D-1] tier 判定依据 nature/level/manual")
owner_node_id = Column(Integer, nullable=True, comment="使用/保管部门标注 org_nodes.id(软链,不派生 tier)")
open_balance = Column(Float, nullable=True, comment="期初余额(万元);未落定留 NULL")
open_period = Column(String(20), nullable=True, comment="期初期间 YYYY-MM")
is_pool_member = Column(Integer, default=0, comment="阶段三资金池用")
status = Column(String(20), default="active", comment="active/inactive/closed")
remark = Column(String(500), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class TreasuryBalance(Base):
"""司库·账户头寸(日/期末)——余额 = 期初 + 流水;禁把流水净额当余额"""
__tablename__ = "treasury_balances"
__table_args__ = (
UniqueConstraint("account_id", "period", name="uq_tbal_acct_period"),
Index("ix_tbal_entity_period", "entity_id", "period"),
Index("ix_tbal_entity_status", "entity_id", "data_status"),
{"comment": "司库·账户头寸(日/期末)"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False)
account_id = Column(Integer, nullable=False, comment="treasury_accounts.id(软链)")
period = Column(String(20), nullable=False, comment="YYYY-MM-DD 日 / YYYY-MM 月末")
balance = Column(Float, nullable=False, comment="账面余额(万元)")
available = Column(Float, nullable=True, comment="可用余额(万元)(NULL=未采集)")
frozen = Column(Float, default=0, comment="冻结/在途(万元)")
source = Column(String(30), nullable=False, comment="ledger/import/manual/pool")
# [P2-6] fail-safe:默认 estimated,verified 必须显式写入
data_status = Column(String(20), default="estimated",
comment="verified/estimated;默认 estimated(fail-safe),estimated 禁 block")
batch = Column(String(100), nullable=True, comment="导入批次指纹(幂等)")
created_at = Column(DateTime, server_default=func.now())
class TreasuryControlRule(Base):
"""司库·资金内控规则库(P0 核心)"""
__tablename__ = "treasury_control_rules"
__table_args__ = (
UniqueConstraint("entity_id", "rule_code", name="uq_tcr_entity_code"),
{"comment": "司库·资金内控规则库"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False)
rule_code = Column(String(20), nullable=False, comment="NP-001/AU-001/BL-001/LQ-001/...")
rule_name = Column(String(100), nullable=False)
rule_type = Column(String(30), nullable=False, comment="budget_required/authorization/dual_sign/liquidity/...")
severity = Column(String(10), nullable=False, comment="block/warn")
params = Column(JSON, nullable=True, comment="阈值参数(金额段/下限,不硬编码)")
rule_version = Column(String(20), default="v1", comment="[调整2] 规则版本(写入 hits.detail)")
enabled = Column(Integer, default=1)
remark = Column(String(500), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class TreasuryPayment(Base):
"""司库·支付单(资金内控闸门载体)"""
__tablename__ = "treasury_payments"
__table_args__ = (
UniqueConstraint("entity_id", "pay_no", name="uq_tpay_entity_no"),
Index("ix_tpay_entity_status", "entity_id", "status"),
Index("ix_tpay_daysplit", "entity_id", "pay_date", "to_name", "submitter"),
Index("ix_tpay_acct", "entity_id", "from_account_id", "status"),
{"comment": "司库·支付单(资金内控闸门)"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False)
pay_no = Column(String(50), nullable=False, comment="支付单号")
from_account_id = Column(Integer, nullable=False, comment="付款账户(必须已登记)")
to_name = Column(String(200), nullable=False, comment="收款方")
to_account_no = Column(String(64), nullable=True)
amount = Column(Float, nullable=False, comment="金额(万元)")
currency = Column(String(10), default="CNY")
purpose = Column(String(500), nullable=False, comment="用途(必填)")
pay_type = Column(String(30), nullable=True, comment="货款/费用/税费/薪酬/融资/内部划转")
pay_date = Column(Date, nullable=True, comment="业务付款日(拆单累计按此自然日归集)")
budget_plan_id = Column(Integer, nullable=True, comment="关联 budget_plans.id(NP-001 首选判据)")
kpi_id = Column(Integer, nullable=True)
related_plan_id = Column(Integer, nullable=True, comment="关联 cash_plans.id(只读复用)")
status = Column(String(20), default="draft",
comment="draft/precheck/pending_approval/approved/rejected/paid/cancelled")
check_result = Column(String(20), nullable=True, comment="pass/blocked/warn")
check_detail = Column(JSON, nullable=True, comment="[{rule_code,verdict,detail,matched_scope}]")
required_approvals = Column(Integer, nullable=True, comment="[D-2] 本单所需审批级数(取最严档)")
day_cum_amount = Column(Float, nullable=True, comment="[D-3] 同提交人×同收款方×同自然日累计(万元,含本单)")
approvals = Column(JSON, nullable=True, comment="[{account,role,person_name,at,comment}]")
dual_sign_effective = Column(Integer, nullable=True, comment="BL-001:0=形式双签(放行无效,标红)")
is_waived = Column(Integer, default=0, comment="[D-4] 是否经豁免通道放行")
submitter = Column(String(100), nullable=False, comment="[D-8] 提交人(拆单累计判据)")
submitted_at = Column(DateTime, nullable=True, comment="[D-5] 提交时间")
approved_at = Column(DateTime, nullable=True, comment="[D-5] 放行时间")
paid_at = Column(DateTime, nullable=True)
created_by = Column(String(100), nullable=False, comment="[D-8] 建单人(首版与 submitter 同值)")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class TreasuryControlHit(Base):
"""司库·内控规则命中留痕(append-only 审计)"""
__tablename__ = "treasury_control_hits"
__table_args__ = (
Index("ix_tch_target", "target_type", "target_id"),
Index("ix_tch_entity_rule", "entity_id", "rule_code", "created_at"),
Index("ix_tch_waived", "entity_id", "verdict", "created_at"),
{"comment": "司库·内控规则命中留痕(append-only 审计)"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False)
target_type = Column(String(20), nullable=False, comment="payment/plan/internal_transfer")
target_id = Column(Integer, nullable=False)
rule_code = Column(String(20), nullable=False)
verdict = Column(String(20), nullable=False, comment="pass/blocked/waived/warn")
detail = Column(JSON, nullable=True,
comment="[调整2] {rule_code,rule_version,matched_scope,threshold,cumulative,verdict_basis}")
waived_by = Column(String(100), nullable=True, comment="豁免审批人(级别不得低于本单所需)")
waived_reason = Column(String(500), nullable=True)
created_at = Column(DateTime, server_default=func.now())
class TreasuryLimit(Base):
"""司库·分级授权矩阵(角色 × 金额段 × 审批级数;金额段左闭右开)"""
__tablename__ = "treasury_limits"
__table_args__ = (
UniqueConstraint("entity_id", "scope_type", "scope_value", "amount_from", name="uq_tlim_scope"),
Index("ix_tlim_entity", "entity_id", "status"),
{"comment": "司库·分级授权矩阵"},
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False)
scope_type = Column(String(20), nullable=False, comment="role/account/tier")
scope_value = Column(String(50), nullable=False, comment="finance/ceo/.../account_id/tier")
amount_from = Column(Float, nullable=False, comment="段下限(万元,含)")
amount_to = Column(Float, nullable=True, comment="段上限(万元,不含),NULL=无上限")
required_approvals = Column(Integer, default=1, comment="所需审批级数(≥2=双签)")
approver_roles = Column(JSON, nullable=False, comment='["ceo","finance"](先复核后批准)')
require_budget = Column(Integer, default=1)
daily_cum_limit = Column(Float, nullable=True, comment="自主档日累计上限(=3×A)")
status = Column(String(20), default="active")
created_at = Column(DateTime, server_default=func.now(), comment="[D-9] 审计必需")